From 72a446b22d2ee2b85a7904fdde0fc7edb82db564 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 1 Mar 2021 12:28:08 +0800 Subject: [PATCH 001/400] fix: app version setup issue --- Mastodon/Supporting Files/AppDelegate.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index cfac7f1a5..00d839b51 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -13,11 +13,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appContext = AppContext() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") + + return true } // MARK: UISceneSession Lifecycle From 25c3d6e74de480170515df09d0a4aa24c928f81b Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 1 Mar 2021 14:23:45 +0800 Subject: [PATCH 002/400] feat: add welcome illustration assets --- Mastodon.xcodeproj/project.pbxproj | 12 +++++ .../Welcome/illustration/Contents.json | 9 ++++ .../background.cyan.colorset/Contents.json | 20 ++++++++ .../cloud.base.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0007_Group-6.png | Bin 0 -> 31513 bytes .../cloud.first.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0008_Group-3.png | Bin 0 -> 6655 bytes .../cloud.second.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0010_Group-5.png | Bin 0 -> 6968 bytes .../cloud.third.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0008_Group-3.png | Bin 0 -> 6655 bytes .../Contents.json | 21 ++++++++ .../Untitled-1_0004_Group-11.png | Bin 0 -> 67690 bytes .../Contents.json | 21 ++++++++ .../Untitled-1_0006_Group-2.png | Bin 0 -> 40329 bytes .../Contents.json | 21 ++++++++ .../Untitled-1_0003_Group-1.png | Bin 0 -> 126997 bytes .../Contents.json | 21 ++++++++ .../Untitled-1_0005_Group-10.png | Bin 0 -> 70951 bytes .../elephant.two.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0001_Group-9.png | Bin 0 -> 65668 bytes .../line.dash.two.imageset/Contents.json | 21 ++++++++ .../Untitled-1_0002_Layer-25.png | Bin 0 -> 5577 bytes .../View/WelcomeIllustrationView.swift | 45 ++++++++++++++++++ 24 files changed, 296 insertions(+) create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Untitled-1_0007_Group-6.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0010_Group-5.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Untitled-1_0008_Group-3.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Untitled-1_0004_Group-11.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Untitled-1_0006_Group-2.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Untitled-1_0003_Group-1.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.four.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.four.imageset/Untitled-1_0005_Group-10.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.two.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.two.imageset/Untitled-1_0001_Group-9.png create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Untitled-1_0002_Layer-25.png create mode 100644 Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e5429eeac..e611a693a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -375,6 +376,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -438,6 +440,7 @@ 0FAA0FDD25E0B5700017CCDE /* Welcome */ = { isa = PBXGroup; children = ( + DBABE3F125ECAC4E00879EE5 /* View */, 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */, ); path = Welcome; @@ -1069,6 +1072,14 @@ path = ViewModel; sourceTree = ""; }; + DBABE3F125ECAC4E00879EE5 /* View */ = { + isa = PBXGroup; + children = ( + DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */, + ); + path = View; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1447,6 +1458,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json new file mode 100644 index 000000000..cd6391d81 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "232", + "green" : "207", + "red" : "60" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json new file mode 100644 index 000000000..25e92a0d5 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0007_Group-6.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Untitled-1_0007_Group-6.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Untitled-1_0007_Group-6.png new file mode 100644 index 0000000000000000000000000000000000000000..c78dbd4b3e4e92cc7f98c235b6b798215d49f05f GIT binary patch literal 31513 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 z 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 PAdenCeo%VP?9IOc5vTA~ literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json new file mode 100644 index 000000000..b84ec128e --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0008_Group-3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png new file mode 100644 index 0000000000000000000000000000000000000000..1a982e42e405ef307e8f32d5915fb2e2a8a525fc GIT binary patch literal 6655 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 R`S%X3rKYc1uk85f{{T}+PS5}V literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json new file mode 100644 index 000000000..f8243850d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0010_Group-5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0010_Group-5.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0010_Group-5.png new file mode 100644 index 0000000000000000000000000000000000000000..6729b733d5253c5f5e57fc1f9af14f2903db7f06 GIT binary patch literal 6968 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 literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Contents.json new file mode 100644 index 000000000..b84ec128e --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0008_Group-3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Untitled-1_0008_Group-3.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.third.imageset/Untitled-1_0008_Group-3.png new file mode 100644 index 0000000000000000000000000000000000000000..1a982e42e405ef307e8f32d5915fb2e2a8a525fc GIT binary patch literal 6655 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 R`S%X3rKYc1uk85f{{T}+PS5}V literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Contents.json new file mode 100644 index 000000000..ec7b694df --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0004_Group-11.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Untitled-1_0004_Group-11.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.four.on.grass.with.tree.two.imageset/Untitled-1_0004_Group-11.png new file mode 100644 index 0000000000000000000000000000000000000000..cbc5ee9cf90b2c14abdc3facdad5f4c8c0837ad7 GIT binary patch literal 67690 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�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%0D>3?ns#^1D|;kRj1X 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�YstX4I8a~9#)yF-QR*`W%c29ej=T2@=CWlKyZd`57uo{&-T$z&T%}xy zzz$o~tN!w!rq;6M#J5p1cmDF5nn^QuaM1NilyhPAZXQQfSN2>asfZYn$uH~}>ZoLp zQ9_KhLMtI7u#(59WfCYimbA?ikxd^7eRV?<^M{ehRJPz=VIs_Qd6Bk$&jiWzMsS2c zKtI+lDM^tsNmW^~Gw$9P=}WbLB6#EwA&QIs!UOe=l7)ASp6&QP`I9hb3y%4Vz`qcr zFjc?@RI#NSkDW<8U6jsA5%TT$@aXzQM*MBaYKl{i@e-(O+_^CGd-=NI+iW31S8@*K z7?xl(Biz#}`UR|G&z~H%6AWR9>pvr8nbCuz|M%huIqDx#8ecZJnK>syYVU&7+JhgG z-IHxF*E0@#K&mlz4p*a~Sr|7-CC68AikgPx{e@3&)XD|B1xV(dFJqlX-WUjYG}N?J J->F!H{U5EAnB)Ke literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json new file mode 100644 index 000000000..5b5d30224 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0003_Group-1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Untitled-1_0003_Group-1.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Untitled-1_0003_Group-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6a58f6f44d526106e2540c56a83799801c8a2c39 GIT binary patch literal 126997 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 JSCda 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%0Ao7{vmf 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 INPYbBe_*5em;e9( literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Contents.json new file mode 100644 index 000000000..b52da407b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Untitled-1_0002_Layer-25.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Untitled-1_0002_Layer-25.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/line.dash.two.imageset/Untitled-1_0002_Layer-25.png new file mode 100644 index 0000000000000000000000000000000000000000..f1137e8395d59f90912f59518be7acb94152ec8a GIT binary patch literal 5577 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 Date: Mon, 1 Mar 2021 15:40:40 +0800 Subject: [PATCH 003/400] chore: add error detail --- .../Entity/Mastodon+Entity+Error.swift | 4 +- .../Entity/Mastodon+Entity+ErrorDetail.swift | 101 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift index 9d5ae2a50..a36025745 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift @@ -19,10 +19,12 @@ extension Mastodon.Entity { public struct Error: Codable { public let error: String public let errorDescription: String? - + public let details: ErrorDetail? + enum CodingKeys: String, CodingKey { case error case errorDescription = "error_description" + case details } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift new file mode 100644 index 000000000..397475e0c --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift @@ -0,0 +1,101 @@ +// +// Mastodon+Entity+ErrorDetail.swift +// +// +// Created by sxiaojian on 2021/3/1. +// + +import Foundation +extension Mastodon.Entity.Error { + /// ERR_BLOCKED When e-mail provider is not allowed + /// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) + /// ERR_TAKEN When username or e-mail are already taken + /// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin" + /// ERR_ACCEPTED When agreement has not been accepted + /// ERR_BLANK When a required attribute is blank + /// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address + /// ERR_TOO_LONG When an attribute is over the character limit + /// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale + public enum SignUpError: RawRepresentable, Codable { + case ERR_BLOCKED + case ERR_UNREACHABLE + case ERR_TAKEN + case ERR_RESERVED + case ERR_ACCEPTED + case ERR_BLANK + case ERR_INVALID + case ERR_TOO_LONG + case ERR_TOO_SHORT + case ERR_INCLUSION + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "ERR_BLOCKED": self = .ERR_BLOCKED + case "ERR_UNREACHABLE": self = .ERR_UNREACHABLE + case "ERR_TAKEN": self = .ERR_TAKEN + case "ERR_RESERVED": self = .ERR_RESERVED + case "ERR_ACCEPTED": self = .ERR_ACCEPTED + case "ERR_BLANK": self = .ERR_BLANK + case "ERR_INVALID": self = .ERR_INVALID + case "ERR_TOO_LONG": self = .ERR_TOO_LONG + case "ERR_TOO_SHORT": self = .ERR_TOO_SHORT + case "ERR_INCLUSION": self = .ERR_INCLUSION + + default: + self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .ERR_BLOCKED: return "ERR_BLOCKED" + case .ERR_UNREACHABLE: return "ERR_UNREACHABLE" + case .ERR_TAKEN: return "ERR_TAKEN" + case .ERR_RESERVED: return "ERR_RESERVED" + case .ERR_ACCEPTED: return "ERR_ACCEPTED" + case .ERR_BLANK: return "ERR_BLANK" + case .ERR_INVALID: return "ERR_INVALID" + case .ERR_TOO_LONG: return "ERR_TOO_LONG" + case .ERR_TOO_SHORT: return "ERR_TOO_SHORT" + case .ERR_INCLUSION: return "ERR_INCLUSION" + + case ._other(let value): return value + } + } + } +} + +public struct ErrorDetail: Codable { + public let username: [ErrorDetailReson]? + public let email: [ErrorDetailReson]? + public let password: [ErrorDetailReson]? + public let agreement: [ErrorDetailReson]? + public let locale: [ErrorDetailReson]? + public let reason: [ErrorDetailReson]? + + enum CodingKeys: String, CodingKey { + case username + case email + case password + case agreement + case locale + case reason + } +} + +public struct ErrorDetailReson: Codable { + public init(error: String, errorDescription: String?) { + self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error) + self.errorDescription = errorDescription + } + + public let error: Mastodon.Entity.Error.SignUpError + public let errorDescription: String? + + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "description" + } +} From cff6a1d9efd3105fec46570a86a401f02088970d Mon Sep 17 00:00:00 2001 From: jk234ert Date: Mon, 1 Mar 2021 17:08:31 +0800 Subject: [PATCH 004/400] fix: #30 fix: fix crash in server pick view when user input search text fix: fix in pick server view, user cound is always zero --- .../PickServer/MastodonPickServerViewController.swift | 5 +++-- .../Onboarding/PickServer/MastodonPickServerViewModel.swift | 2 -- .../Onboarding/PickServer/TableViewCell/PickServerCell.swift | 2 +- .../MastodonSDK/Entity/Mastodon+Entity+Instance.swift | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 9e10cd329..fb42192a5 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -103,7 +103,9 @@ extension MastodonPickServerViewController { .sink { _ in } receiveValue: { [weak self] servers in + self?.tableView.beginUpdates() self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic) + self?.tableView.endUpdates() if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) { // Previously selected server is still in the list, do nothing } else { @@ -291,8 +293,7 @@ extension MastodonPickServerViewController: UITableViewDelegate { // Same reason as above return 10 case .serverList: - // Header with 1 height as the separator - return 1 + return 0 } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 3a701f09f..a3b2a8768 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -80,8 +80,6 @@ class MastodonPickServerViewModel: NSObject { weak var tableView: UITableView? -// private var expandServerDomainSet = Set() - var mastodonPinBasedAuthenticationViewController: UIViewController? init(context: AppContext, mode: PickServerMode) { diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 711822186..0ded9392f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -231,7 +231,7 @@ extension PickServerCell { // Set bottom separator seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: seperator.bottomAnchor), + containerView.topAnchor.constraint(equalTo: seperator.topAnchor), seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), domainLabel.topAnchor.constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift index 18e41b3e0..226af40f8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Instance.swift @@ -47,7 +47,7 @@ extension Mastodon.Entity { case approvalRequired = "approval_required" case invitesEnabled = "invites_enabled" case urls - case statistics + case statistics = "stats" case thumbnail case contactAccount = "contact_account" From 732c5392d416303723902e9dd370353c43dd8df2 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 1 Mar 2021 16:27:24 +0800 Subject: [PATCH 005/400] chore: show error with 18n --- Localization/app.json | 20 ++++ Mastodon.xcodeproj/project.pbxproj | 4 + .../Mastodon+Entidy+ErrorDetailReson.swift | 94 +++++++++++++++++++ Mastodon/Extension/UIAlertController.swift | 42 ++++++++- Mastodon/Generated/Strings.swift | 36 +++++++ .../Resources/en.lproj/Localizable.strings | 18 +++- .../MastodonRegisterViewController.swift | 15 +++ .../MastodonServerRulesViewController.swift | 2 + .../Entity/Mastodon+Entity+ErrorDetail.swift | 61 ++++++------ 9 files changed, 260 insertions(+), 32 deletions(-) create mode 100644 Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift diff --git a/Localization/app.json b/Localization/app.json index e20e901db..a28b6e7f7 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -1,5 +1,25 @@ { "common": { + "errors": { + "item": { + "username": "username", + "email": "email", + "password": "password", + "agreement": "agreement", + "locale": "locale", + "reason": "reason" + }, + "ERR_BLOCKED": "is blocked", + "ERR_UNREACHABLE": "is unreachable", + "ERR_TAKEN": "is taken", + "ERR_RESERVED": "is reserved", + "ERR_ACCEPTED": "must be accepted", + "ERR_BLANK": "can't be blank", + "ERR_INVALID": "is invalid", + "ERR_TOO_LONG": "is too long", + "ERR_TOO_SHORT": "is too short", + "ERR_INCLUSION": "is inclusion" + }, "alerts": { "sign_up_failure": { "title": "Sign Up Failure" diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e5429eeac..4b93b1f99 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; + 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -260,6 +261,7 @@ 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReson.swift"; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -1007,6 +1009,7 @@ DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, + 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */, ); path = Extension; sourceTree = ""; @@ -1502,6 +1505,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, + 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift new file mode 100644 index 000000000..c78e4dfc4 --- /dev/null +++ b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift @@ -0,0 +1,94 @@ +// +// Mastodon+Entidy+ErrorDetailReason.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/1. +// +import MastodonSDK + +extension Mastodon.Entity.ErrorDetailReason { + func localizedDescription() -> String { + switch self.error { + case .ERR_BLOCKED: + return L10n.Common.Errors.errBlocked + case .ERR_UNREACHABLE: + return L10n.Common.Errors.errUnreachable + case .ERR_TAKEN: + return L10n.Common.Errors.errTaken + case .ERR_RESERVED: + return L10n.Common.Errors.errReserved + case .ERR_ACCEPTED: + return L10n.Common.Errors.errAccepted + case .ERR_BLANK: + return L10n.Common.Errors.errBlank + case .ERR_INVALID: + return L10n.Common.Errors.errInvalid + case .ERR_TOO_LONG: + return L10n.Common.Errors.errTooLong + case .ERR_TOO_SHORT: + return L10n.Common.Errors.errTooShort + case .ERR_INCLUSION: + return L10n.Common.Errors.errInclusion + case ._other: + return self.errorDescription ?? "" + } + } +} + +extension Mastodon.Entity.ErrorDetail { + func localizedDescription() -> String { + var messages: [String?] = [] + if let username = self.username { + if !username.isEmpty { + let errors = username.map { + L10n.Common.Errors.Item.username + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + if let email = self.email { + if !email.isEmpty { + let errors = email.map { + L10n.Common.Errors.Item.email + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + if let password = self.password { + if !password.isEmpty { + let errors = password.map { + L10n.Common.Errors.Item.password + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + if let agreement = self.agreement { + if !agreement.isEmpty { + let errors = agreement.map { + L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + if let locale = self.locale { + if !locale.isEmpty { + let errors = locale.map { + L10n.Common.Errors.Item.locale + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + if let reason = self.reason { + if !reason.isEmpty { + let errors = reason.map { + L10n.Common.Errors.Item.reason + " " + $0.localizedDescription() + } + messages.append(contentsOf: errors) + } + } + let message = messages + .compactMap { $0 } + .joined(separator: ", ") + return message + } +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift index 83c0ff555..755acc1ae 100644 --- a/Mastodon/Extension/UIAlertController.swift +++ b/Mastodon/Extension/UIAlertController.swift @@ -4,7 +4,7 @@ // import UIKit - +import MastodonSDK // Reference: // https://nshipster.com/swift-foundation-error-protocols/ extension UIAlertController { @@ -43,3 +43,43 @@ extension UIAlertController { } } +extension UIAlertController { + convenience init( + for error: Mastodon.API.Error, + title: String?, + preferredStyle: UIAlertController.Style + ) { + let _title: String + let message: String? + switch error.mastodonError { + case .generic(let mastodonEntityError): + + if let title = title { + _title = title + } else { + _title = error.errorDescription ?? "Error" + } + var messages: [String?] = [] + if let details = mastodonEntityError.details { + message = details.localizedDescription() + } else { + messages.append(contentsOf: [ + error.failureReason, + error.recoverySuggestion + ]) + message = messages + .compactMap { $0 } + .joined(separator: " ") + } + default: + _title = "Internal Error" + message = error.localizedDescription + } + + self.init( + title: _title, + message: message, + preferredStyle: preferredStyle + ) + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 47cbabab8..ce4ab38f8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -82,6 +82,42 @@ internal enum L10n { internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single") } } + internal enum Errors { + /// must be accepted + internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted") + /// can't be blank + internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank") + /// is blocked + internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked") + /// is inclusion + internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion") + /// is invalid + internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid") + /// is reserved + internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved") + /// is taken + internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken") + /// is too long + internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong") + /// is too short + internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort") + /// is unreachable + internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable") + internal enum Item { + /// agreement + internal static let agreement = L10n.tr("Localizable", "Common.Errors.Item.Agreement") + /// email + internal static let email = L10n.tr("Localizable", "Common.Errors.Item.Email") + /// locale + internal static let locale = L10n.tr("Localizable", "Common.Errors.Item.Locale") + /// password + internal static let password = L10n.tr("Localizable", "Common.Errors.Item.Password") + /// reason + internal static let reason = L10n.tr("Localizable", "Common.Errors.Item.Reason") + /// username + internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username") + } + } } internal enum Scene { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index b3df9a77f..663ad25ad 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -23,6 +23,22 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Common.Errors.ErrAccepted" = "must be accepted"; +"Common.Errors.ErrBlank" = "can't be blank"; +"Common.Errors.ErrBlocked" = "is blocked"; +"Common.Errors.ErrInclusion" = "is inclusion"; +"Common.Errors.ErrInvalid" = "is invalid"; +"Common.Errors.ErrReserved" = "is reserved"; +"Common.Errors.ErrTaken" = "is taken"; +"Common.Errors.ErrTooLong" = "is too long"; +"Common.Errors.ErrTooShort" = "is too short"; +"Common.Errors.ErrUnreachable" = "is unreachable"; +"Common.Errors.Item.Agreement" = "agreement"; +"Common.Errors.Item.Email" = "email"; +"Common.Errors.Item.Locale" = "locale"; +"Common.Errors.Item.Password" = "password"; +"Common.Errors.Item.Reason" = "reason"; +"Common.Errors.Item.Username" = "username"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; @@ -62,4 +78,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index ff979c3dd..9bdaafd03 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -105,6 +105,20 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let usernameIsTakenLabel: UILabel = { let label = UILabel() + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) + let attributeString = NSMutableAttributedString() + + let errorImage = NSTextAttachment() + let configuration = UIImage.SymbolConfiguration(font: font) + errorImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(color) + let errorImageAttachment = NSAttributedString(attachment: errorImage) + attributeString.append(errorImageAttachment) + + let errorString = NSAttributedString(string: L10n.Common.Errors.Item.username + " " + L10n.Common.Errors.errTaken, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + attributeString.append(errorString) + label.attributedText = attributeString + return label }() @@ -392,6 +406,7 @@ extension MastodonRegisterViewController { .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } + guard let error = error as? Mastodon.API.Error else { return } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index cc7992e21..3886bda7c 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import MastodonSDK final class MastodonServerRulesViewController: UIViewController, NeedsDependency { @@ -162,6 +163,7 @@ extension MastodonServerRulesViewController { .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } + guard let error = error as? Mastodon.API.Error else { return } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift index 397475e0c..4d90779bb 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift @@ -65,37 +65,38 @@ extension Mastodon.Entity.Error { } } } +extension Mastodon.Entity { + public struct ErrorDetail: Codable { + public let username: [ErrorDetailReason]? + public let email: [ErrorDetailReason]? + public let password: [ErrorDetailReason]? + public let agreement: [ErrorDetailReason]? + public let locale: [ErrorDetailReason]? + public let reason: [ErrorDetailReason]? -public struct ErrorDetail: Codable { - public let username: [ErrorDetailReson]? - public let email: [ErrorDetailReson]? - public let password: [ErrorDetailReson]? - public let agreement: [ErrorDetailReson]? - public let locale: [ErrorDetailReson]? - public let reason: [ErrorDetailReson]? + enum CodingKeys: String, CodingKey { + case username + case email + case password + case agreement + case locale + case reason + } + } - enum CodingKeys: String, CodingKey { - case username - case email - case password - case agreement - case locale - case reason - } -} - -public struct ErrorDetailReson: Codable { - public init(error: String, errorDescription: String?) { - self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error) - self.errorDescription = errorDescription - } - - public let error: Mastodon.Entity.Error.SignUpError - public let errorDescription: String? - - - enum CodingKeys: String, CodingKey { - case error - case errorDescription = "description" + public struct ErrorDetailReason: Codable { + public init(error: String, errorDescription: String?) { + self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error) + self.errorDescription = errorDescription + } + + public let error: Mastodon.Entity.Error.SignUpError + public let errorDescription: String? + + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "description" + } } } From 47abbadabaa3a97cabc0bca3dfb7f2a65e89dde2 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 1 Mar 2021 17:38:45 +0800 Subject: [PATCH 006/400] feat: draw welcome illustration --- Mastodon/Generated/Assets.swift | 13 ++ .../cloud.first.imageset/Contents.json | 2 +- .../Untitled-1_0008_Group-3.png | Bin 6655 -> 0 bytes .../Untitled-1_0010_Group-5.png | Bin .../cloud.second.imageset/Contents.json | 2 +- .../Untitled-1_0009_Group-4.png | Bin 0 -> 6802 bytes .../View/WelcomeIllustrationView.swift | 111 +++++++++++++++++- .../Welcome/WelcomeViewController.swift | 38 +++++- .../Container/MosaicImageViewContainer.swift | 26 ++-- .../Scene/Share/View/Content/StatusView.swift | 4 +- 10 files changed, 173 insertions(+), 23 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png rename Mastodon/Resources/Assets.xcassets/Welcome/illustration/{cloud.second.imageset => cloud.first.imageset}/Untitled-1_0010_Group-5.png (100%) create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0009_Group-4.png diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..46780bb6a 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -69,6 +69,19 @@ internal enum Asset { internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Welcome { + internal enum Illustration { + internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") + internal static let cloudBase = ImageAsset(name: "Welcome/illustration/cloud.base") + internal static let cloudFirst = ImageAsset(name: "Welcome/illustration/cloud.first") + internal static let cloudSecond = ImageAsset(name: "Welcome/illustration/cloud.second") + internal static let cloudThird = ImageAsset(name: "Welcome/illustration/cloud.third") + internal static let elephantFourOnGrassWithTreeTwo = ImageAsset(name: "Welcome/illustration/elephant.four.on.grass.with.tree.two") + internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Welcome/illustration/elephant.on.airplane.with.contrail") + internal static let elephantThreeOnGrass = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass") + internal static let elephantThreeOnGrassWithTreeFour = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass.with.tree.four") + internal static let elephantTwo = ImageAsset(name: "Welcome/illustration/elephant.two") + internal static let lineDashTwo = ImageAsset(name: "Welcome/illustration/line.dash.two") + } internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") } diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json index b84ec128e..f8243850d 100644 --- a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Untitled-1_0008_Group-3.png", + "filename" : "Untitled-1_0010_Group-5.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0008_Group-3.png deleted file mode 100644 index 1a982e42e405ef307e8f32d5915fb2e2a8a525fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6655 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 R`S%X3rKYc1uk85f{{T}+PS5}V diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0010_Group-5.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0010_Group-5.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0010_Group-5.png rename to Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.first.imageset/Untitled-1_0010_Group-5.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json index f8243850d..005f195fa 100644 --- a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Untitled-1_0010_Group-5.png", + "filename" : "Untitled-1_0009_Group-4.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0009_Group-4.png b/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.second.imageset/Untitled-1_0009_Group-4.png new file mode 100644 index 0000000000000000000000000000000000000000..efcdca6c25896433c49652f496b32e08ad1a5510 GIT binary patch literal 6802 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%0AAIp!i-&=q9phP5|;q!fq~) z7d`cF_$J?e$C0(lO$<&qG)0u%-dx?C->Ggh*X8ZQvF7;#dA;Fa>4hgz1^4J+jkSG~ zcE^ohSPxmFwhg{^xw_7*r9zg9_pt_<>$T2#v_-hCD_sg&GpXJn`fVQhxBQ8V@Qp0xyxLY(po$?0YD@e0RqwN-I`inatWFIc z92-qi7846#P4?h>Zl{ss&LapO#zls?cUSqHH5%oG&qS#mXSSxb2KX?5GYvn$&)!;VJetA14qZLsm)1ntl3K-I zyO}??#T5`9u-{U(9@=#l<2*xFmssIojz>RS)tavff8Mv4{NkE)tL-rg#piS&p~rJ$ zKF{fvM>$}|+HvPbU6bIe5JrQeK$;~Xne+^{W8Gd?3Ep&BC+bPx$Y+KI3&jqzu<&P8 z59eZpd(}0i;5`cOPaeM*ZsdW~JbD(%cy~ypnSQ@(U}}FoxY@MGnzTrxH+eX~w8NgM z8my5eQxhH?t4N{x1$;u?pAN9qCN>(6U}@iS8CZa8h}wVaxbp9(G*a8gM=@{T<{U`c zE@ z&OnVf(nCiemR-cxaP=O({+1CEQS{2J!V&*Ib@(13L}WGEI`bZJ9m(hZ!JH_KRDQ?p z+T}fq3O5zZ1SjIzpf65pDoUIh$+t-5T87*gV1IkYOV3!fU{p&ve}xs{q!$Zc<*AkkN}T?cpXSE$;c_!@se#~rUVn|8+(|B9r(f;2Hv zfhCs{1Xdr0YU2Bt=%_d%obBVjDw`}z#;0qRM?!BTOYq6p`I1Hv8c%M%Jcqzs`_@?C z)CCQEoxPewRb~~m*1n@0XU%VB^hU^f>RGEKvbeo0`}ed^CLucZ%@!Eg#}68fA6Py_ za>+eTCT=%}<+b8MC`LO0rUN++NH0$H?MOeu6DZ!B6Ac<8VYo_=QIXk%HrepE% ziB|$`@gXM5$2ceBQ73BA4%RbYsU({B=s8~ZAgV+1oPymQ;$Qvu0oL1DcZ;#D`CJ=f zp(+rvd`{@Fps8A$ZP>k&)#m=tC=tV!<5vRkZncj_wB ziNx~Ghi&4sMlHs>lU2?a<(a0=1+`n>XFqaMj1(5r;$el6%w9OcTeH z8YIS=PLxqrYSc~`DxS(Ad0ubwA+UbyGSl1PJCP_GW1ee>UC#K*bx5F<+G?={D(%;; zuvbFg>{9~UcBx!X;`qZ)c-rylyB8NT94PsxQ(hQfS`OP&+lSJa?pC2UnTz5JEjiQ#XI&m?JMBw~s(qAlF|`be^d>kN)`p2#N{a*RmU%35#IYJzSa<)^Z0q3%tCgHnF-J0uez*)E=lx5^! UIImage { + let size = artworkImageSize + let width = artworkImageSize.width + let height = artworkImageSize.height + let image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: artworkImageSize)) + + // draw cloud + let cloudBaseImage = Asset.Welcome.Illustration.cloudBase.image + cloudBaseImage.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height)) + + let elephantFourOnGrassWithTreeTwoImage = Asset.Welcome.Illustration.elephantFourOnGrassWithTreeTwo.image + let elephantThreeOnGrassWithTreeFourImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeFour.image + let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image + let elephantTwoImage = Asset.Welcome.Illustration.elephantTwo.image + let ineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image + + let elephantOnAirplaneWithContrailImageView = Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image + + // draw elephantFourOnGrassWithTreeTwo + // elephantFourOnGrassWithTreeTwo.bottomY + 40 align to elephantThreeOnGrassImage.centerY + elephantFourOnGrassWithTreeTwoImage.draw(at: CGPoint(x: 0, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantFourOnGrassWithTreeTwoImage.size.height - 40)) + + // draw elephantThreeOnGrassWithTreeFour + // elephantThreeOnGrassWithTreeFour.bottomY + 40 align to elephantThreeOnGrassImage.centerY + elephantThreeOnGrassWithTreeFourImage.draw(at: CGPoint(x: width - elephantThreeOnGrassWithTreeFourImage.size.width, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeFourImage.size.height - 40)) + + // draw elephantThreeOnGrass + elephantThreeOnGrassImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassImage.size.height)) + + // darw ineDashTwoImage + ineDashTwoImage.draw(at: CGPoint(x: 0.5 * elephantThreeOnGrassImage.size.width + 60, y: height - elephantThreeOnGrassImage.size.height - 50)) + + // draw elephantTwo.image + elephantTwoImage.draw(at: CGPoint(x: 0, y: height - elephantTwoImage.size.height - 125)) + + // draw elephantOnAirplaneWithContrailImageView + elephantOnAirplaneWithContrailImageView.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height - 0.5 * elephantOnAirplaneWithContrailImageView.size.height)) + } + + return image } } @@ -33,10 +128,20 @@ import SwiftUI struct WelcomeIllustrationView_Previews: PreviewProvider { static var previews: some View { - UIViewPreview(width: 375) { - WelcomeIllustrationView() + Group { + UIViewPreview(width: 870) { + WelcomeIllustrationView() + } + .previewLayout(.fixed(width: 870, height: 2000)) + UIViewPreview(width: 375) { + WelcomeIllustrationView() + } + .previewLayout(.fixed(width: 375, height: 812)) + UIViewPreview(width: 428) { + WelcomeIllustrationView() + } + .previewLayout(.fixed(width: 428, height: 926)) } - .previewLayout(.fixed(width: 375, height: 812)) } } diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e832e5a43..dc6a0b0cd 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -13,6 +13,9 @@ final class WelcomeViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + let welcomeIllustrationView = WelcomeIllustrationView() + var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint! + private(set) lazy var logoImageView: UIImageView = { let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Welcome.mastodonLogo.image : Asset.Welcome.mastodonLogoLarge.image let imageView = UIImageView(image: image) @@ -42,7 +45,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(UIColor.white.withAlphaComponent(0.8), for: .normal) button.setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -60,6 +63,16 @@ extension WelcomeViewController { super.viewDidLoad() setupOnboardingAppearance() + view.backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color + + welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView) + welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + NSLayoutConstraint.activate([ + welcomeIllustrationView.leftAnchor.constraint(equalTo: view.leftAnchor), + welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor), + welcomeIllustrationViewBottomAnchorLayoutConstraint, + ]) view.addSubview(logoImageView) NSLayoutConstraint.activate([ @@ -76,6 +89,19 @@ extension WelcomeViewController { sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168), ]) + welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false + welcomeIllustrationView.cloudSecondImageView.translatesAutoresizingMaskIntoConstraints = false + welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false + +// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) +// NSLayoutConstraint.activate([ +// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor.constraint(equalTo: view.leftAnchor), +// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor), +// ]) +// welcomeIllustrationView.welcomeIllustrationView.sca +// view.bringSubviewToFront(sloganLabel) + view.addSubview(signInButton) view.addSubview(signUpButton) NSLayoutConstraint.activate([ @@ -94,8 +120,14 @@ extension WelcomeViewController { signInButton.addTarget(self, action: #selector(signInButtonDidClicked(_:)), for: .touchUpInside) } - override var preferredStatusBarStyle: UIStatusBarStyle { return .darkContent } - + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + // make illustration bottom over the bleeding + let overlap: CGFloat = 100 + welcomeIllustrationViewBottomAnchorLayoutConstraint.constant = overlap - view.safeAreaInsets.bottom + } + } extension WelcomeViewController { diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 5240d4e2c..71984800b 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -287,11 +287,11 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[3] - let imageView = view.setupImageView( + let artworkImageView = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) - imageView.image = image + artworkImageView.image = image return view } .previewLayout(.fixed(width: 375, height: 400)) @@ -299,14 +299,14 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[1] - let imageView = view.setupImageView( + let artworkImageView = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) - imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = 8 - imageView.contentMode = .scaleAspectFill - imageView.image = image + artworkImageView.layer.masksToBounds = true + artworkImageView.layer.cornerRadius = 8 + artworkImageView.contentMode = .scaleAspectFill + artworkImageView.image = image return view } .previewLayout(.fixed(width: 375, height: 400)) @@ -315,8 +315,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(2) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { - imageView.image = images[i] + for (i, artworkImageView) in imageViews.enumerated() { + artworkImageView.image = images[i] } return view } @@ -326,8 +326,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(3) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { - imageView.image = images[i] + for (i, artworkImageView) in imageViews.enumerated() { + artworkImageView.image = images[i] } return view } @@ -337,8 +337,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(4) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { - imageView.image = images[i] + for (i, artworkImageView) in imageViews.enumerated() { + artworkImageView.image = images[i] } return view } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index be754ed86..46a416fd0 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -358,8 +358,8 @@ struct StatusView_Previews: PreviewProvider { statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { - imageView.image = images[i] + for (i, artworkImageView) in imageViews.enumerated() { + artworkImageView.image = images[i] } statusView.statusMosaicImageView.isHidden = false return statusView From a659b35577511db24d2851d18dcb4689942349db Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 1 Mar 2021 17:45:02 +0800 Subject: [PATCH 007/400] chore: display UI when username is taken error return by sign up --- .../MastodonRegisterViewController.swift | 43 +++++++++++++++---- .../Register/MastodonRegisterViewModel.swift | 2 + 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 9bdaafd03..2f92e44cc 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -241,11 +241,12 @@ extension MastodonRegisterViewController { stackView.addArrangedSubview(largeTitleLabel) stackView.addArrangedSubview(photoView) stackView.addArrangedSubview(usernameTextField) + stackView.addArrangedSubview(usernameIsTakenLabel) stackView.addArrangedSubview(displayNameTextField) stackView.addArrangedSubview(emailTextField) stackView.addArrangedSubview(passwordTextField) stackView.addArrangedSubview(passwordCheckLabel) - if self.viewModel.approvalRequired { + if viewModel.approvalRequired { stackView.addArrangedSubview(inviteTextField) } // scrollView @@ -389,24 +390,48 @@ extension MastodonRegisterViewController { guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid) - } .store(in: &disposeBag) viewModel.isAllValid - .receive(on: DispatchQueue.main) - .sink { [weak self] isAllValid in - guard let self = self else { return } - self.signUpButton.isEnabled = isAllValid - } - .store(in: &disposeBag) + .receive(on: DispatchQueue.main) + .sink { [weak self] isAllValid in + guard let self = self else { return } + self.signUpButton.isEnabled = isAllValid + } + .store(in: &disposeBag) + viewModel.isUsernameTaken + .receive(on: DispatchQueue.main) + .sink {[weak self] isUsernameTaken in + guard let self = self else { return } + if isUsernameTaken { + self.usernameIsTakenLabel.isHidden = false + stackView.setCustomSpacing(6, after: self.usernameTextField) + stackView.setCustomSpacing(16, after: self.usernameIsTakenLabel) + } else { + self.usernameIsTakenLabel.isHidden = true + stackView.setCustomSpacing(40, after: self.usernameTextField) + } + } + .store(in: &disposeBag) viewModel.error .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } guard let error = error as? Mastodon.API.Error else { return } + switch error.mastodonError { + case .generic(let mastodonEntityError): + if let usernameTakenError = mastodonEntityError.details?.username { + let isUsernameAvaliable = usernameTakenError.filter { errorDetailReason -> Bool in + errorDetailReason.error == .ERR_TAKEN + }.isEmpty + self.viewModel.isUsernameTaken.value = !isUsernameAvaliable + } + default: + break + } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) @@ -454,7 +479,7 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) - if self.viewModel.approvalRequired { + if viewModel.approvalRequired { inviteTextField.delegate = self NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 5a9098347..8f930771a 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -34,6 +34,8 @@ final class MastodonRegisterViewModel { let passwordValidateState = CurrentValueSubject(.empty) let inviteValidateState = CurrentValueSubject(.empty) + let isUsernameTaken = CurrentValueSubject(false) + let isRegistering = CurrentValueSubject(false) let isAllValid = CurrentValueSubject(false) let error = CurrentValueSubject(nil) From 77a0708e7dd81ce30ed8fbb9ffe630ebfe0a378b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 1 Mar 2021 18:06:37 +0800 Subject: [PATCH 008/400] chore: Make the server rules display before the sign-up form scene --- .../MastodonPickServerViewController.swift | 26 +++++--- .../MastodonRegisterViewController.swift | 60 +++++++------------ .../MastodonServerRulesViewController.swift | 54 +---------------- .../MastodonServerRulesViewModel.swift | 21 +++---- 4 files changed, 51 insertions(+), 110 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 9e10cd329..7aef5817e 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -265,13 +265,25 @@ extension MastodonPickServerViewController { } } receiveValue: { [weak self] response in guard let self = self else { return } - let mastodonRegisterViewModel = MastodonRegisterViewModel( - domain: server.domain, - authenticateInfo: response.authenticateInfo, - instance: response.instance.value, - applicationToken: response.applicationToken.value - ) - self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show) + if let rules = response.instance.value.rules, !rules.isEmpty { + // show server rules before register + let mastodonServerRulesViewModel = MastodonServerRulesViewModel( + domain: server.domain, + authenticateInfo: response.authenticateInfo, + rules: rules, + instance: response.instance.value, + applicationToken: response.applicationToken.value + ) + self.coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) + } else { + let mastodonRegisterViewModel = MastodonRegisterViewModel( + domain: server.domain, + authenticateInfo: response.authenticateInfo, + instance: response.instance.value, + applicationToken: response.applicationToken.value + ) + self.coordinator.present(scene: .mastodonRegister(viewModel: mastodonRegisterViewModel), from: nil, transition: .show) + } } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 2f92e44cc..9931a8b19 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -576,45 +576,29 @@ extension MastodonRegisterViewController { locale: "en" // TODO: ) - if let rules = viewModel.instance.rules, !rules.isEmpty { - // show server rules before register - let mastodonServerRulesViewModel = MastodonServerRulesViewModel( - context: context, - domain: viewModel.domain, - authenticateInfo: viewModel.authenticateInfo, - rules: rules, - registerQuery: query, - applicationAuthorization: viewModel.applicationAuthorization - ) - - viewModel.isRegistering.value = false - view.endEditing(true) - coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) - return - } else { - // register without show server rules - context.apiService.accountRegister( - domain: viewModel.domain, - query: query, - authorization: viewModel.applicationAuthorization - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - self.viewModel.isRegistering.value = false - switch completion { - case .failure(let error): - self.viewModel.error.send(error) - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) - self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) + // register without show server rules + context.apiService.accountRegister( + domain: viewModel.domain, + query: query, + authorization: viewModel.applicationAuthorization + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.viewModel.isRegistering.value = false + switch completion { + case .failure(let error): + self.viewModel.error.send(error) + case .finished: + break } - .store(in: &disposeBag) + } receiveValue: { [weak self] response in + guard let self = self else { return } + let userToken = response.value + let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) + self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) } + .store(in: &disposeBag) + } } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 3886bda7c..467239b87 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -8,7 +8,6 @@ import os.log import UIKit import Combine -import MastodonSDK final class MastodonServerRulesViewController: UIViewController, NeedsDependency { @@ -149,31 +148,6 @@ extension MastodonServerRulesViewController { rulesLabel.attributedText = viewModel.rulesAttributedString confirmButton.addTarget(self, action: #selector(MastodonServerRulesViewController.confirmButtonPressed(_:)), for: .touchUpInside) - - viewModel.isRegistering - .receive(on: DispatchQueue.main) - .sink { [weak self] isRegistering in - guard let self = self else { return } - isRegistering ? self.confirmButton.showLoading() : self.confirmButton.stopLoading() - } - .store(in: &disposeBag) - - viewModel.error - .compactMap { $0 } - .receive(on: DispatchQueue.main) - .sink { [weak self] error in - guard let self = self else { return } - guard let error = error as? Mastodon.API.Error else { return } - let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) - alertController.addAction(okAction) - self.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - } - .store(in: &disposeBag) } override func viewDidLayoutSubviews() { @@ -199,31 +173,9 @@ extension MastodonServerRulesViewController { extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let email = viewModel.registerQuery.email - - context.apiService.accountRegister( - domain: viewModel.domain, - query: viewModel.registerQuery, - authorization: viewModel.applicationAuthorization - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - self.viewModel.isRegistering.value = false - switch completion { - case .failure(let error): - self.viewModel.error.send(error) - case .finished: - break - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) - self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) - } - .store(in: &disposeBag) + + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 9569ffe81..89f31bbc2 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -10,34 +10,27 @@ import Combine import MastodonSDK final class MastodonServerRulesViewModel { - // input - let context: AppContext + let domain: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let rules: [Mastodon.Entity.Instance.Rule] - let registerQuery: Mastodon.API.Account.RegisterQuery - let applicationAuthorization: Mastodon.API.OAuth.Authorization - - // output - let isRegistering = CurrentValueSubject(false) - let error = CurrentValueSubject(nil) + let instance: Mastodon.Entity.Instance + let applicationToken: Mastodon.Entity.Token init( - context: AppContext, domain: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, rules: [Mastodon.Entity.Instance.Rule], - registerQuery: Mastodon.API.Account.RegisterQuery, - applicationAuthorization: Mastodon.API.OAuth.Authorization + instance: Mastodon.Entity.Instance, + applicationToken: Mastodon.Entity.Token ) { - self.context = context self.domain = domain self.authenticateInfo = authenticateInfo self.rules = rules - self.registerQuery = registerQuery - self.applicationAuthorization = applicationAuthorization + self.instance = instance + self.applicationToken = applicationToken } var rulesAttributedString: NSAttributedString { From 9251b55106b69e3a837c264660f04da84dd68c02 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 1 Mar 2021 18:32:31 +0800 Subject: [PATCH 009/400] feat: set artwork image bleeding --- .../View/WelcomeIllustrationView.swift | 9 +- .../Welcome/WelcomeViewController.swift | 83 +++++++++++++------ 2 files changed, 61 insertions(+), 31 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index e11a2df50..9c5008ee5 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -73,10 +73,10 @@ extension WelcomeIllustrationView { override func layoutSubviews() { super.layoutSubviews() - artworkImageView.image = WelcomeIllustrationView.bottomPartImage() + artworkImageView.image = WelcomeIllustrationView.artworkImage() } - static func bottomPartImage() -> UIImage { + static func artworkImage() -> UIImage { let size = artworkImageSize let width = artworkImageSize.width let height = artworkImageSize.height @@ -95,7 +95,7 @@ extension WelcomeIllustrationView { let elephantTwoImage = Asset.Welcome.Illustration.elephantTwo.image let ineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image - let elephantOnAirplaneWithContrailImageView = Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image + // let elephantOnAirplaneWithContrailImageView = Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image // draw elephantFourOnGrassWithTreeTwo // elephantFourOnGrassWithTreeTwo.bottomY + 40 align to elephantThreeOnGrassImage.centerY @@ -115,11 +115,12 @@ extension WelcomeIllustrationView { elephantTwoImage.draw(at: CGPoint(x: 0, y: height - elephantTwoImage.size.height - 125)) // draw elephantOnAirplaneWithContrailImageView - elephantOnAirplaneWithContrailImageView.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height - 0.5 * elephantOnAirplaneWithContrailImageView.size.height)) + // elephantOnAirplaneWithContrailImageView.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height - 0.5 * elephantOnAirplaneWithContrailImageView.size.height)) } return image } + } #if canImport(SwiftUI) && DEBUG diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index dc6a0b0cd..abfbd8856 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -14,7 +14,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } let welcomeIllustrationView = WelcomeIllustrationView() - var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint! + var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint? private(set) lazy var logoImageView: UIImageView = { let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Welcome.mastodonLogo.image : Asset.Welcome.mastodonLogoLarge.image @@ -41,12 +41,12 @@ final class WelcomeViewController: UIViewController, NeedsDependency { return button }() - let signInButton: UIButton = { + private(set) lazy var signInButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - button.setTitleColor(UIColor.white.withAlphaComponent(0.8), for: .normal) - button.setInsets(forContentPadding: UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0), imageTitlePadding: 0) + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.highlight.color + button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -63,16 +63,18 @@ extension WelcomeViewController { super.viewDidLoad() setupOnboardingAppearance() - view.backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color - welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(welcomeIllustrationView) - welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - NSLayoutConstraint.activate([ - welcomeIllustrationView.leftAnchor.constraint(equalTo: view.leftAnchor), - welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor), - welcomeIllustrationViewBottomAnchorLayoutConstraint, - ]) + if traitCollection.userInterfaceIdiom == .phone { + view.backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color + welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView) + welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + NSLayoutConstraint.activate([ + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.leftAnchor, constant: 44), + welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 44), + welcomeIllustrationViewBottomAnchorLayoutConstraint!, + ]) + } view.addSubview(logoImageView) NSLayoutConstraint.activate([ @@ -89,18 +91,45 @@ extension WelcomeViewController { sloganLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 168), ]) - welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false - welcomeIllustrationView.cloudSecondImageView.translatesAutoresizingMaskIntoConstraints = false - welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false - -// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) -// NSLayoutConstraint.activate([ -// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor.constraint(equalTo: view.leftAnchor), -// welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor), -// ]) -// welcomeIllustrationView.welcomeIllustrationView.sca -// view.bringSubviewToFront(sloganLabel) + if traitCollection.userInterfaceIdiom == .phone { + let imageSizeScale: CGFloat = view.frame.width > 375 ? 1.5 : 1.0 + welcomeIllustrationView.cloudFirstImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.cloudFirstImageView) + NSLayoutConstraint.activate([ + welcomeIllustrationView.cloudFirstImageView.rightAnchor.constraint(equalTo: view.centerXAnchor), + welcomeIllustrationView.cloudFirstImageView.widthAnchor.constraint(equalToConstant: 272 / traitCollection.displayScale * imageSizeScale), + welcomeIllustrationView.cloudFirstImageView.heightAnchor.constraint(equalToConstant: 113 / traitCollection.displayScale * imageSizeScale), + ]) + welcomeIllustrationView.cloudSecondImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.cloudSecondImageView) + NSLayoutConstraint.activate([ + welcomeIllustrationView.cloudSecondImageView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor), + welcomeIllustrationView.cloudSecondImageView.rightAnchor.constraint(equalTo: logoImageView.rightAnchor, constant: 20), + welcomeIllustrationView.cloudSecondImageView.widthAnchor.constraint(equalToConstant: 152 / traitCollection.displayScale), + welcomeIllustrationView.cloudSecondImageView.heightAnchor.constraint(equalToConstant: 96 / traitCollection.displayScale), + welcomeIllustrationView.cloudFirstImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudSecondImageView.bottomAnchor), + ]) + welcomeIllustrationView.cloudThirdImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.cloudThirdImageView) + NSLayoutConstraint.activate([ + logoImageView.topAnchor.constraint(equalTo: welcomeIllustrationView.cloudThirdImageView.bottomAnchor, constant: 10), + welcomeIllustrationView.cloudThirdImageView.rightAnchor.constraint(equalTo: view.centerXAnchor), + welcomeIllustrationView.cloudThirdImageView.widthAnchor.constraint(equalToConstant: 126 / traitCollection.displayScale), + welcomeIllustrationView.cloudThirdImageView.heightAnchor.constraint(equalToConstant: 68 / traitCollection.displayScale), + ]) + + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) + NSLayoutConstraint.activate([ + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor.constraint(equalTo: view.leftAnchor), + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor), + // make a little bit large + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalToConstant: 656 / traitCollection.displayScale * imageSizeScale), + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalToConstant: 195 / traitCollection.displayScale * imageSizeScale), + ]) + view.bringSubviewToFront(logoImageView) + view.bringSubviewToFront(sloganLabel) + } view.addSubview(signInButton) view.addSubview(signUpButton) @@ -124,8 +153,8 @@ extension WelcomeViewController { super.viewSafeAreaInsetsDidChange() // make illustration bottom over the bleeding - let overlap: CGFloat = 100 - welcomeIllustrationViewBottomAnchorLayoutConstraint.constant = overlap - view.safeAreaInsets.bottom + let overlap: CGFloat = 145 + welcomeIllustrationViewBottomAnchorLayoutConstraint?.constant = overlap - view.safeAreaInsets.bottom } } From 148a996129726fd8ad0cb19dd109c4e9ebf6df0e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 1 Mar 2021 19:19:51 +0800 Subject: [PATCH 010/400] chore: update the i18n suggests --- Localization/app.json | 20 +++--- .../Mastodon+Entidy+ErrorDetailReson.swift | 66 +++++++++---------- Mastodon/Generated/Strings.swift | 22 ++++--- .../Resources/en.lproj/Localizable.strings | 18 ++--- .../Register/MastodonRegisterViewModel.swift | 2 +- .../Entity/Mastodon+Entity+ErrorDetail.swift | 18 ++--- 6 files changed, 78 insertions(+), 68 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index a28b6e7f7..b5d375a15 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -9,16 +9,20 @@ "locale": "locale", "reason": "reason" }, - "ERR_BLOCKED": "is blocked", - "ERR_UNREACHABLE": "is unreachable", - "ERR_TAKEN": "is taken", - "ERR_RESERVED": "is reserved", + "itemDetail": { + "emailInvalid": "It's not a valid e-mail address", + "usernameInvalid": "username only contains alphanumeric characters and underscores" + }, + "ERR_BLOCKED": "contains a disallowed e-mail provider", + "ERR_UNREACHABLE": "does not seem to exist", + "ERR_TAKEN": "is already in use", + "ERR_RESERVED": "is a reserved keyword or username", "ERR_ACCEPTED": "must be accepted", - "ERR_BLANK": "can't be blank", + "ERR_BLANK": "is required", "ERR_INVALID": "is invalid", - "ERR_TOO_LONG": "is too long", - "ERR_TOO_SHORT": "is too short", - "ERR_INCLUSION": "is inclusion" + "ERR_TOO_LONG": "is too long ( can't be longer than 30 characters)", + "ERR_TOO_SHORT": "is too short (must be at least 8 characters)", + "ERR_INCLUSION": "is not a supported value" }, "alerts": { "sign_up_failure": { diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift index c78e4dfc4..e72e2771f 100644 --- a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift +++ b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift @@ -38,53 +38,51 @@ extension Mastodon.Entity.ErrorDetailReason { extension Mastodon.Entity.ErrorDetail { func localizedDescription() -> String { var messages: [String?] = [] - if let username = self.username { - if !username.isEmpty { - let errors = username.map { - L10n.Common.Errors.Item.username + " " + $0.localizedDescription() + + if let username = self.username, !username.isEmpty { + let errors = username.map { errorDetailReason -> String in + if errorDetailReason.error == .ERR_INVALID { + return L10n.Common.Errors.Itemdetail.usernameinvalid + } else { + return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription() } - messages.append(contentsOf: errors) } + messages.append(contentsOf: errors) } - if let email = self.email { - if !email.isEmpty { - let errors = email.map { - L10n.Common.Errors.Item.email + " " + $0.localizedDescription() + + if let email = self.email, !email.isEmpty { + let errors = email.map { errorDetailReason -> String in + if errorDetailReason.error == .ERR_INVALID { + return L10n.Common.Errors.Itemdetail.emailinvalid + } else { + return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription() } - messages.append(contentsOf: errors) } + messages.append(contentsOf: errors) } - if let password = self.password { - if !password.isEmpty { - let errors = password.map { - L10n.Common.Errors.Item.password + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) + if let password = self.password,!password.isEmpty { + let errors = password.map { + L10n.Common.Errors.Item.password + " " + $0.localizedDescription() } + messages.append(contentsOf: errors) } - if let agreement = self.agreement { - if !agreement.isEmpty { - let errors = agreement.map { - L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) + if let agreement = self.agreement, !agreement.isEmpty { + let errors = agreement.map { + L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription() } + messages.append(contentsOf: errors) } - if let locale = self.locale { - if !locale.isEmpty { - let errors = locale.map { - L10n.Common.Errors.Item.locale + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) + if let locale = self.locale, !locale.isEmpty { + let errors = locale.map { + L10n.Common.Errors.Item.locale + " " + $0.localizedDescription() } + messages.append(contentsOf: errors) } - if let reason = self.reason { - if !reason.isEmpty { - let errors = reason.map { - L10n.Common.Errors.Item.reason + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) + if let reason = self.reason, !reason.isEmpty { + let errors = reason.map { + L10n.Common.Errors.Item.reason + " " + $0.localizedDescription() } + messages.append(contentsOf: errors) } let message = messages .compactMap { $0 } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index ce4ab38f8..d9e7de9e5 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -85,23 +85,23 @@ internal enum L10n { internal enum Errors { /// must be accepted internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted") - /// can't be blank + /// is required internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank") - /// is blocked + /// contains a disallowed e-mail provider internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked") - /// is inclusion + /// is not a supported value internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion") /// is invalid internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid") - /// is reserved + /// is a reserved keyword or username internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved") - /// is taken + /// is already in use internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken") - /// is too long + /// is too long ( can't be longer than 30 characters) internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong") - /// is too short + /// is too short (must be at least 8 characters) internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort") - /// is unreachable + /// does not seem to exist internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable") internal enum Item { /// agreement @@ -117,6 +117,12 @@ internal enum L10n { /// username internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username") } + internal enum Itemdetail { + /// It's not a valid e-mail address + internal static let emailinvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.Emailinvalid") + /// username only contains alphanumeric characters and underscores + internal static let usernameinvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.Usernameinvalid") + } } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 663ad25ad..d127a93a3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -24,21 +24,23 @@ "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; "Common.Errors.ErrAccepted" = "must be accepted"; -"Common.Errors.ErrBlank" = "can't be blank"; -"Common.Errors.ErrBlocked" = "is blocked"; -"Common.Errors.ErrInclusion" = "is inclusion"; +"Common.Errors.ErrBlank" = "is required"; +"Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider"; +"Common.Errors.ErrInclusion" = "is not a supported value"; "Common.Errors.ErrInvalid" = "is invalid"; -"Common.Errors.ErrReserved" = "is reserved"; -"Common.Errors.ErrTaken" = "is taken"; -"Common.Errors.ErrTooLong" = "is too long"; -"Common.Errors.ErrTooShort" = "is too short"; -"Common.Errors.ErrUnreachable" = "is unreachable"; +"Common.Errors.ErrReserved" = "is a reserved keyword or username"; +"Common.Errors.ErrTaken" = "is already in use"; +"Common.Errors.ErrTooLong" = "is too long ( can't be longer than 30 characters)"; +"Common.Errors.ErrTooShort" = "is too short (must be at least 8 characters)"; +"Common.Errors.ErrUnreachable" = "does not seem to exist"; "Common.Errors.Item.Agreement" = "agreement"; "Common.Errors.Item.Email" = "email"; "Common.Errors.Item.Locale" = "locale"; "Common.Errors.Item.Password" = "password"; "Common.Errors.Item.Reason" = "reason"; "Common.Errors.Item.Username" = "username"; +"Common.Errors.Itemdetail.Emailinvalid" = "It's not a valid e-mail address"; +"Common.Errors.Itemdetail.Usernameinvalid" = "username only contains alphanumeric characters and underscores"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 8f930771a..a32e5d040 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -160,7 +160,7 @@ extension MastodonRegisterViewModel { let falseColor = UIColor.clear let attributeString = NSMutableAttributedString() - let start = NSAttributedString(string: "Your password needs at least:\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + let start = NSAttributedString(string: "Your password needs at least:", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) attributeString.append(start) attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift index 4d90779bb..7881aaa0d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift @@ -7,15 +7,15 @@ import Foundation extension Mastodon.Entity.Error { - /// ERR_BLOCKED When e-mail provider is not allowed - /// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) - /// ERR_TAKEN When username or e-mail are already taken - /// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin" - /// ERR_ACCEPTED When agreement has not been accepted - /// ERR_BLANK When a required attribute is blank - /// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address - /// ERR_TOO_LONG When an attribute is over the character limit - /// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale + /// ERR_BLOCKED When e-mail provider is not allowed + /// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) + /// ERR_TAKEN When username or e-mail are already taken + /// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin" + /// ERR_ACCEPTED When agreement has not been accepted + /// ERR_BLANK When a required attribute is blank + /// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address + /// ERR_TOO_LONG When an attribute is over the character limit + /// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale public enum SignUpError: RawRepresentable, Codable { case ERR_BLOCKED case ERR_UNREACHABLE From 94d1aae02c50bdbec2edcb1312d46008fac0e483 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 1 Mar 2021 19:34:24 +0800 Subject: [PATCH 011/400] fix: Xcode dummy refactor rename namespace overflow issue --- .../Container/MosaicImageViewContainer.swift | 26 +++++++++---------- .../Scene/Share/View/Content/StatusView.swift | 4 +-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 71984800b..5240d4e2c 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -287,11 +287,11 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[3] - let artworkImageView = view.setupImageView( + let imageView = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) - artworkImageView.image = image + imageView.image = image return view } .previewLayout(.fixed(width: 375, height: 400)) @@ -299,14 +299,14 @@ struct MosaicImageView_Previews: PreviewProvider { UIViewPreview(width: 375) { let view = MosaicImageViewContainer() let image = images[1] - let artworkImageView = view.setupImageView( + let imageView = view.setupImageView( aspectRatio: image.size, maxSize: CGSize(width: 375, height: 400) ) - artworkImageView.layer.masksToBounds = true - artworkImageView.layer.cornerRadius = 8 - artworkImageView.contentMode = .scaleAspectFill - artworkImageView.image = image + imageView.layer.masksToBounds = true + imageView.layer.cornerRadius = 8 + imageView.contentMode = .scaleAspectFill + imageView.image = image return view } .previewLayout(.fixed(width: 375, height: 400)) @@ -315,8 +315,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(2) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, artworkImageView) in imageViews.enumerated() { - artworkImageView.image = images[i] + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] } return view } @@ -326,8 +326,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(3) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, artworkImageView) in imageViews.enumerated() { - artworkImageView.image = images[i] + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] } return view } @@ -337,8 +337,8 @@ struct MosaicImageView_Previews: PreviewProvider { let view = MosaicImageViewContainer() let images = self.images.prefix(4) let imageViews = view.setupImageViews(count: images.count, maxHeight: 162) - for (i, artworkImageView) in imageViews.enumerated() { - artworkImageView.image = images[i] + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] } return view } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 46a416fd0..be754ed86 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -358,8 +358,8 @@ struct StatusView_Previews: PreviewProvider { statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162) - for (i, artworkImageView) in imageViews.enumerated() { - artworkImageView.image = images[i] + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] } statusView.statusMosaicImageView.isHidden = false return statusView From f6d9b127224346111320660a0227ed7f0a5e4285 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 10:25:49 +0800 Subject: [PATCH 012/400] chore: update the i18n suggests --- Localization/app.json | 12 ++++++---- Mastodon.xcodeproj/project.pbxproj | 12 ++++++---- ...> Mastodon+Entidy+ErrorDetailReason.swift} | 23 ++++++++++++------- Mastodon/Extension/String.swift | 18 +++++++++++++++ Mastodon/Generated/Strings.swift | 18 +++++++++------ .../Resources/en.lproj/Localizable.strings | 12 ++++++---- 6 files changed, 66 insertions(+), 29 deletions(-) rename Mastodon/Extension/{Mastodon+Entidy+ErrorDetailReson.swift => Mastodon+Entidy+ErrorDetailReason.swift} (80%) create mode 100644 Mastodon/Extension/String.swift diff --git a/Localization/app.json b/Localization/app.json index b5d375a15..3a45922a5 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -10,18 +10,20 @@ "reason": "reason" }, "itemDetail": { - "emailInvalid": "It's not a valid e-mail address", - "usernameInvalid": "username only contains alphanumeric characters and underscores" + "email_invalid": "This is not a valid e-mail address", + "username_invalid": "Username must only contain alphanumeric characters and underscores", + "password_too_shrot": "password is too short (must be at least 8 characters)", + "username_too_long": "username is too long (can't be longer than 30 characters)" }, "ERR_BLOCKED": "contains a disallowed e-mail provider", "ERR_UNREACHABLE": "does not seem to exist", "ERR_TAKEN": "is already in use", - "ERR_RESERVED": "is a reserved keyword or username", + "ERR_RESERVED": "is a reserved keyword", "ERR_ACCEPTED": "must be accepted", "ERR_BLANK": "is required", "ERR_INVALID": "is invalid", - "ERR_TOO_LONG": "is too long ( can't be longer than 30 characters)", - "ERR_TOO_SHORT": "is too short (must be at least 8 characters)", + "ERR_TOO_LONG": "is too long", + "ERR_TOO_SHORT": "is too short", "ERR_INCLUSION": "is not a supported value" }, "alerts": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4b93b1f99..9b5894130 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */; }; + 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -71,6 +71,7 @@ 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; + 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; @@ -261,7 +262,7 @@ 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReson.swift"; sourceTree = ""; }; + 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -277,6 +278,7 @@ 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; + 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -1009,7 +1011,8 @@ DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift */, + 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, + 2D939AB425EDD8A90076FA61 /* String.swift */, ); path = Extension; sourceTree = ""; @@ -1460,6 +1463,7 @@ DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, @@ -1505,7 +1509,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReson.swift in Sources */, + 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift similarity index 80% rename from Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift rename to Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift index e72e2771f..cc1a47907 100644 --- a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReson.swift +++ b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entidy+ErrorDetailReason.swift +// Mastodon+Entity+ErrorDetailReason.swift // Mastodon // // Created by sxiaojian on 2021/3/1. @@ -41,9 +41,12 @@ extension Mastodon.Entity.ErrorDetail { if let username = self.username, !username.isEmpty { let errors = username.map { errorDetailReason -> String in - if errorDetailReason.error == .ERR_INVALID { - return L10n.Common.Errors.Itemdetail.usernameinvalid - } else { + switch errorDetailReason.error { + case .ERR_INVALID: + return L10n.Common.Errors.Itemdetail.usernameInvalid + case .ERR_TOO_LONG: + return L10n.Common.Errors.Itemdetail.usernameTooLong + default: return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription() } } @@ -53,7 +56,7 @@ extension Mastodon.Entity.ErrorDetail { if let email = self.email, !email.isEmpty { let errors = email.map { errorDetailReason -> String in if errorDetailReason.error == .ERR_INVALID { - return L10n.Common.Errors.Itemdetail.emailinvalid + return L10n.Common.Errors.Itemdetail.emailInvalid } else { return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription() } @@ -61,8 +64,12 @@ extension Mastodon.Entity.ErrorDetail { messages.append(contentsOf: errors) } if let password = self.password,!password.isEmpty { - let errors = password.map { - L10n.Common.Errors.Item.password + " " + $0.localizedDescription() + let errors = password.map { errorDetailReason -> String in + if errorDetailReason.error == .ERR_TOO_SHORT { + return L10n.Common.Errors.Itemdetail.passwordTooShrot + } else { + return L10n.Common.Errors.Item.password + " " + errorDetailReason.localizedDescription() + } } messages.append(contentsOf: errors) } @@ -87,6 +94,6 @@ extension Mastodon.Entity.ErrorDetail { let message = messages .compactMap { $0 } .joined(separator: ", ") - return message + return message.capitalizingFirstLetter() } } diff --git a/Mastodon/Extension/String.swift b/Mastodon/Extension/String.swift new file mode 100644 index 000000000..87028ffdf --- /dev/null +++ b/Mastodon/Extension/String.swift @@ -0,0 +1,18 @@ +// +// String.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/2. +// + +import Foundation + +extension String { + func capitalizingFirstLetter() -> String { + return prefix(1).capitalized + dropFirst() + } + + mutating func capitalizeFirstLetter() { + self = self.capitalizingFirstLetter() + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d9e7de9e5..8e93c804e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -93,13 +93,13 @@ internal enum L10n { internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion") /// is invalid internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid") - /// is a reserved keyword or username + /// is a reserved keyword internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved") /// is already in use internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken") - /// is too long ( can't be longer than 30 characters) + /// is too long internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong") - /// is too short (must be at least 8 characters) + /// is too short internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort") /// does not seem to exist internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable") @@ -118,10 +118,14 @@ internal enum L10n { internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username") } internal enum Itemdetail { - /// It's not a valid e-mail address - internal static let emailinvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.Emailinvalid") - /// username only contains alphanumeric characters and underscores - internal static let usernameinvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.Usernameinvalid") + /// This is not a valid e-mail address + internal static let emailInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.EmailInvalid") + /// password is too short (must be at least 8 characters) + internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") + /// Username must only contain alphanumeric characters and underscores + internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") + /// username is too long ( can't be longer than 30 characters) + internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") } } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d127a93a3..191ec0daf 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -28,10 +28,10 @@ "Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider"; "Common.Errors.ErrInclusion" = "is not a supported value"; "Common.Errors.ErrInvalid" = "is invalid"; -"Common.Errors.ErrReserved" = "is a reserved keyword or username"; +"Common.Errors.ErrReserved" = "is a reserved keyword"; "Common.Errors.ErrTaken" = "is already in use"; -"Common.Errors.ErrTooLong" = "is too long ( can't be longer than 30 characters)"; -"Common.Errors.ErrTooShort" = "is too short (must be at least 8 characters)"; +"Common.Errors.ErrTooLong" = "is too long"; +"Common.Errors.ErrTooShort" = "is too short"; "Common.Errors.ErrUnreachable" = "does not seem to exist"; "Common.Errors.Item.Agreement" = "agreement"; "Common.Errors.Item.Email" = "email"; @@ -39,8 +39,10 @@ "Common.Errors.Item.Password" = "password"; "Common.Errors.Item.Reason" = "reason"; "Common.Errors.Item.Username" = "username"; -"Common.Errors.Itemdetail.Emailinvalid" = "It's not a valid e-mail address"; -"Common.Errors.Itemdetail.Usernameinvalid" = "username only contains alphanumeric characters and underscores"; +"Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; +"Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; +"Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; +"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long ( can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; From 08d9f67f003cc9b6eda7b0a8ecc9d33176b4b6b8 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 11:00:59 +0800 Subject: [PATCH 013/400] fix: image name typo issue --- .../Onboarding/Welcome/View/WelcomeIllustrationView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index 9c5008ee5..0e733b74e 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -93,7 +93,7 @@ extension WelcomeIllustrationView { let elephantThreeOnGrassWithTreeFourImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeFour.image let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image let elephantTwoImage = Asset.Welcome.Illustration.elephantTwo.image - let ineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image + let lineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image // let elephantOnAirplaneWithContrailImageView = Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image @@ -109,7 +109,7 @@ extension WelcomeIllustrationView { elephantThreeOnGrassImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassImage.size.height)) // darw ineDashTwoImage - ineDashTwoImage.draw(at: CGPoint(x: 0.5 * elephantThreeOnGrassImage.size.width + 60, y: height - elephantThreeOnGrassImage.size.height - 50)) + lineDashTwoImage.draw(at: CGPoint(x: 0.5 * elephantThreeOnGrassImage.size.width + 60, y: height - elephantThreeOnGrassImage.size.height - 50)) // draw elephantTwo.image elephantTwoImage.draw(at: CGPoint(x: 0, y: height - elephantTwoImage.size.height - 125)) From 965756b0f8f48d9e7c7f0ab5e4a93e290af33706 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 11:49:27 +0800 Subject: [PATCH 014/400] chore: make background black and set alpha 0.9 for the artwork --- Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index abfbd8856..16592f328 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -65,7 +65,6 @@ extension WelcomeViewController { setupOnboardingAppearance() if traitCollection.userInterfaceIdiom == .phone { - view.backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color welcomeIllustrationView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(welcomeIllustrationView) welcomeIllustrationViewBottomAnchorLayoutConstraint = welcomeIllustrationView.bottomAnchor.constraint(equalTo: view.bottomAnchor) @@ -74,6 +73,8 @@ extension WelcomeViewController { welcomeIllustrationView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: 44), welcomeIllustrationViewBottomAnchorLayoutConstraint!, ]) + view.backgroundColor = .black + welcomeIllustrationView.alpha = 0.9 } view.addSubview(logoImageView) From 33f1cfcc77b11073b54585f97f1a3e48da063a9a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 12:01:11 +0800 Subject: [PATCH 015/400] fix: server description may display in HTML format issue. resolve: #30 --- .../PickServer/TableViewCell/PickServerCell.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 0ded9392f..fef09b955 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -8,6 +8,7 @@ import UIKit import MastodonSDK import Kingfisher +import Kanna protocol PickServerCellDelegate: class { func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) @@ -326,7 +327,13 @@ extension PickServerCell { private func updateServerInfo() { guard let serverInfo = server else { return } domainLabel.text = serverInfo.domain - descriptionLabel.text = serverInfo.description + descriptionLabel.text = { + guard let html = try? HTML(html: serverInfo.description, encoding: .utf8) else { + return serverInfo.description + } + + return html.text ?? serverInfo.description + }() let processor = RoundCornerImageProcessor(cornerRadius: 3) thumbImageView.kf.indicatorType = .activity thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [ From eda3e95ad0cffebc2d62ebce6841f0a6666cc201 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 12:49:04 +0800 Subject: [PATCH 016/400] feat: add poll table view cell --- Mastodon.xcodeproj/project.pbxproj | 10 + Mastodon/Extension/CALayer.swift | 51 +++++ Mastodon/Generated/Assets.swift | 1 + .../Contents.json | 24 ++- .../Contents.json | 6 +- .../Label/secondary.colorset/Contents.json | 6 +- .../lightSecondaryText.colorset/Contents.json | 6 +- .../TableviewCell/PollTableViewCell.swift | 200 ++++++++++++++++++ 8 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 Mastodon/Extension/CALayer.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9b5894130..0415385bb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */; }; DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -147,6 +148,8 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */; }; + DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -324,6 +327,7 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MastodonUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; @@ -366,6 +370,8 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.swift; sourceTree = ""; }; + DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -669,6 +675,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -997,6 +1004,7 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, + DB44384E25E8C1FA008912A2 /* CALayer.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -1448,6 +1456,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, @@ -1508,6 +1517,7 @@ 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, + DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, diff --git a/Mastodon/Extension/CALayer.swift b/Mastodon/Extension/CALayer.swift new file mode 100644 index 000000000..41ce739ee --- /dev/null +++ b/Mastodon/Extension/CALayer.swift @@ -0,0 +1,51 @@ +// +// CALayer.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-2-26. +// + +import UIKit + +extension CALayer { + + func setupShadow( + color: UIColor = .black, + alpha: Float = 0.5, + x: CGFloat = 0, + y: CGFloat = 2, + blur: CGFloat = 4, + spread: CGFloat = 0, + roundedRect: CGRect? = nil, + byRoundingCorners corners: UIRectCorner? = nil, + cornerRadii: CGSize? = nil + ) { + // assert(roundedRect != .zero) + shadowColor = color.cgColor + shadowOpacity = alpha + shadowOffset = CGSize(width: x, height: y) + shadowRadius = blur / 2 + rasterizationScale = UIScreen.main.scale + shouldRasterize = true + masksToBounds = false + + guard let roundedRect = roundedRect, + let corners = corners, + let cornerRadii = cornerRadii else { + return + } + + if spread == 0 { + shadowPath = UIBezierPath(roundedRect: roundedRect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } else { + let rect = roundedRect.insetBy(dx: -spread, dy: -spread) + shadowPath = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii).cgPath + } + } + + func removeShadow() { + shadowRadius = 0 + } + + +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..a4f4e0803 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -71,6 +71,7 @@ internal enum Asset { internal enum Welcome { internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") + internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 7e0375939..91dac809a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.216", + "green" : "0.176", + "red" : "0.161" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index edc0dce9a..d097fec40 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json index 70b1446d0..8953c8fb0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0x43", - "green" : "0x3C", - "red" : "0x3C" + "blue" : "67", + "green" : "60", + "red" : "60" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json index 5fb782c4f..ba375b791 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "0.263", - "green" : "0.235", - "red" : "0.235" + "blue" : "67", + "green" : "60", + "red" : "60" } }, "idiom" : "universal" diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift new file mode 100644 index 000000000..d41fd7428 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift @@ -0,0 +1,200 @@ +// +// PollTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-25. +// + +import UIKit + +final class PollTableViewCell: UITableViewCell { + + static let checkmarkImageSize = CGSize(width: 26, height: 26) + + let roundedBackgroundView = UIView() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .systemBackground + return view + }() + + let checkmarkImageView: UIView = { + let imageView = UIImageView() + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.highlight.color + return imageView + }() + + let optionLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .medium) + label.textColor = Asset.Colors.Label.primary.color + label.text = "Option" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right + return label + }() + + let optionPercentageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = "50%" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PollTableViewCell { + + private func _init() { + roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(roundedBackgroundView) + NSLayoutConstraint.activate([ + roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), + roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + ]) + + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), + roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), + ]) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkBackgroundView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), + checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), + checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), + checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), + ]) + + optionLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabel) + NSLayoutConstraint.activate([ + optionLabel.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), + optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor, constant: 8), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + + configureCheckmark(state: .none) + } + + override func layoutSubviews() { + super.layoutSubviews() + + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = roundedBackgroundView.bounds.height * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = checkmarkBackgroundView.bounds.height * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } + +} + +extension PollTableViewCell { + + enum CheckmarkState { + case none + case off + case on + } + + func configureCheckmark(state: CheckmarkState) { + switch state { + case .none: + checkmarkBackgroundView.backgroundColor = .clear + checkmarkImageView.isHidden = true + optionPercentageLabel.isHidden = true + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .off: + checkmarkBackgroundView.backgroundColor = .systemBackground + checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + checkmarkBackgroundView.layer.borderWidth = 1 + checkmarkImageView.isHidden = true + optionPercentageLabel.isHidden = true + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .on: + checkmarkBackgroundView.backgroundColor = .systemBackground + checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + checkmarkBackgroundView.layer.borderWidth = 0 + checkmarkImageView.isHidden = false + optionPercentageLabel.isHidden = false + optionLabel.textColor = .white + optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } + } + +} + + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollTableViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + PollTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollTableViewCell() + cell.configureCheckmark(state: .off) + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + UIViewPreview() { + let cell = PollTableViewCell() + cell.configureCheckmark(state: .on) + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif + From fc9310de2095a3dbe4ea72e5ad94296c15b6cce1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 13:45:47 +0800 Subject: [PATCH 017/400] feat: add motion effect for welcome illustration elements --- Mastodon.xcodeproj/project.pbxproj | 44 ++++-- .../xcschemes/xcschememanagement.plist | 4 +- .../Mastodon+Entidy+ErrorDetailReason.swift | 1 + .../UIInterpolatingMotionEffect.swift | 30 ++++ Mastodon/Generated/Assets.swift | 1 + .../TootContent.swift} | 2 +- .../View/WelcomeIllustrationView.swift | 132 ++++++++++++------ .../Welcome/WelcomeViewController.swift | 35 ++++- Mastodon/Supporting Files/AppDelegate.swift | 8 +- 9 files changed, 194 insertions(+), 63 deletions(-) rename Mastodon/Extension/{ => MastodonSDK}/Mastodon+Entidy+ErrorDetailReason.swift (99%) create mode 100644 Mastodon/Extension/UIInterpolatingMotionEffect.swift rename Mastodon/{Extension/MastodonContent.swift => Helper/TootContent.swift} (99%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8878f8065..6de95a41e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -38,7 +38,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; - 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonContent.swift */; }; + 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; @@ -160,6 +160,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -248,7 +249,7 @@ 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = ""; }; 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = ""; }; - 2D42FF6A25C817D2004A627A /* MastodonContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonContent.swift; sourceTree = ""; }; + 2D42FF6A25C817D2004A627A /* TootContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TootContent.swift; sourceTree = ""; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; @@ -380,6 +381,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -801,6 +803,7 @@ 2D61335525C1886800CAE157 /* Service */, DB8AF55525C1379F002E6C99 /* Scene */, DB8AF54125C13647002E6C99 /* Coordinator */, + DB9E0D6925EDFFE500CFDD76 /* Helper */, DB8AF56225C138BC002E6C99 /* Extension */, 2D5A3D0125CF8640002347D6 /* Vender */, DB5086CB25CC0DB400C2C187 /* Preference */, @@ -1000,22 +1003,22 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, - DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, - DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, - 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, + DB9E0D6425EDFF5600CFDD76 /* MastodonSDK */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, + 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, - 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, + 2D939AB425EDD8A90076FA61 /* String.swift */, + DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, - 2D939AB425EDD8A90076FA61 /* String.swift */, + 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, + 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, + DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, + 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, ); path = Extension; sourceTree = ""; @@ -1078,6 +1081,22 @@ path = ViewModel; sourceTree = ""; }; + DB9E0D6425EDFF5600CFDD76 /* MastodonSDK */ = { + isa = PBXGroup; + children = ( + 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, + ); + path = MastodonSDK; + sourceTree = ""; + }; + DB9E0D6925EDFFE500CFDD76 /* Helper */ = { + isa = PBXGroup; + children = ( + 2D42FF6A25C817D2004A627A /* TootContent.swift */, + ); + path = Helper; + sourceTree = ""; + }; DBABE3F125ECAC4E00879EE5 /* View */ = { isa = PBXGroup; children = ( @@ -1486,6 +1505,7 @@ 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -1540,7 +1560,7 @@ 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, - 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, + 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index b1a7a744c..da5807cfe 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -17,12 +17,12 @@ Mastodon - Release.xcscheme_^#shared#^_ orderHint - 1 + 3 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 SuppressBuildableAutocreation diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entidy+ErrorDetailReason.swift similarity index 99% rename from Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift rename to Mastodon/Extension/MastodonSDK/Mastodon+Entidy+ErrorDetailReason.swift index cc1a47907..500bc1cb8 100644 --- a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entidy+ErrorDetailReason.swift @@ -4,6 +4,7 @@ // // Created by sxiaojian on 2021/3/1. // + import MastodonSDK extension Mastodon.Entity.ErrorDetailReason { diff --git a/Mastodon/Extension/UIInterpolatingMotionEffect.swift b/Mastodon/Extension/UIInterpolatingMotionEffect.swift new file mode 100644 index 000000000..5ab4cb2f5 --- /dev/null +++ b/Mastodon/Extension/UIInterpolatingMotionEffect.swift @@ -0,0 +1,30 @@ +// +// UIInterpolatingMotionEffect.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import UIKit + +extension UIInterpolatingMotionEffect { + static func motionEffect( + minX: CGFloat, + maxX: CGFloat, + minY: CGFloat, + maxY: CGFloat + ) -> UIMotionEffectGroup { + let motionEffectX = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.x", type: .tiltAlongHorizontalAxis) + motionEffectX.minimumRelativeValue = minX + motionEffectX.maximumRelativeValue = maxX + + let motionEffectY = UIInterpolatingMotionEffect(keyPath: "layer.transform.translation.y", type: .tiltAlongVerticalAxis) + motionEffectY.minimumRelativeValue = minY + motionEffectY.maximumRelativeValue = maxY + + let motionEffectGroup = UIMotionEffectGroup() + motionEffectGroup.motionEffects = [motionEffectX, motionEffectY] + + return motionEffectGroup + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 46780bb6a..c909b6206 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -84,6 +84,7 @@ internal enum Asset { } internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") + internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Extension/MastodonContent.swift b/Mastodon/Helper/TootContent.swift similarity index 99% rename from Mastodon/Extension/MastodonContent.swift rename to Mastodon/Helper/TootContent.swift index b1b1a635f..55f71beac 100755 --- a/Mastodon/Extension/MastodonContent.swift +++ b/Mastodon/Helper/TootContent.swift @@ -1,5 +1,5 @@ // -// MastodonContent.swift +// TootContent.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/1. diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index 0e733b74e..4817ffc33 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -10,7 +10,13 @@ import UIKit final class WelcomeIllustrationView: UIView { static let artworkImageSize = CGSize(width: 870, height: 2000) - let artworkImageView = UIImageView() + + let cloudBaseImageView = UIImageView() + let rightHillImageView = UIImageView() + let leftHillImageView = UIImageView() + let centerHillImageView = UIImageView() + let lineDashTwoImageView = UIImageView() + let elephantTwoImageView = UIImageView() // layout outside let elephantOnAirplaneWithContrailImageView: UIImageView = { @@ -47,6 +53,7 @@ final class WelcomeIllustrationView: UIView { } extension WelcomeIllustrationView { + private func _init() { backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color @@ -60,65 +67,108 @@ extension WelcomeIllustrationView { topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), ]) - artworkImageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(artworkImageView) + cloudBaseImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(cloudBaseImageView) NSLayoutConstraint.activate([ - artworkImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), - artworkImageView.leadingAnchor.constraint(equalTo: leadingAnchor), - artworkImageView.trailingAnchor.constraint(equalTo: trailingAnchor), - artworkImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - artworkImageView.widthAnchor.constraint(equalTo: artworkImageView.heightAnchor, multiplier: WelcomeIllustrationView.artworkImageSize.width / WelcomeIllustrationView.artworkImageSize.height), + cloudBaseImageView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + cloudBaseImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + cloudBaseImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + cloudBaseImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + cloudBaseImageView.widthAnchor.constraint(equalTo: cloudBaseImageView.heightAnchor, multiplier: WelcomeIllustrationView.artworkImageSize.width / WelcomeIllustrationView.artworkImageSize.height), ]) + + + [ + rightHillImageView, + leftHillImageView, + centerHillImageView, + lineDashTwoImageView, + elephantTwoImageView, + ].forEach { imageView in + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: cloudBaseImageView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: cloudBaseImageView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: cloudBaseImageView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: cloudBaseImageView.bottomAnchor), + ]) + } } override func layoutSubviews() { super.layoutSubviews() - artworkImageView.image = WelcomeIllustrationView.artworkImage() + updateImage() } - static func artworkImage() -> UIImage { - let size = artworkImageSize - let width = artworkImageSize.width - let height = artworkImageSize.height - let image = UIGraphicsImageRenderer(size: size).image { context in + private func updateImage() { + let size = WelcomeIllustrationView.artworkImageSize + let width = size.width + let height = size.height + + let elephantFourOnGrassWithTreeTwoImage = Asset.Welcome.Illustration.elephantFourOnGrassWithTreeTwo.image + let elephantThreeOnGrassWithTreeFourImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeFour.image + let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image + let elephantTwoImage = Asset.Welcome.Illustration.elephantTwo.image + let lineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image + + + cloudBaseImageView.image = UIGraphicsImageRenderer(size: size).image { context in // clear background UIColor.clear.setFill() - context.fill(CGRect(origin: .zero, size: artworkImageSize)) + context.fill(CGRect(origin: .zero, size: size)) // draw cloud let cloudBaseImage = Asset.Welcome.Illustration.cloudBase.image cloudBaseImage.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height)) - - let elephantFourOnGrassWithTreeTwoImage = Asset.Welcome.Illustration.elephantFourOnGrassWithTreeTwo.image - let elephantThreeOnGrassWithTreeFourImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeFour.image - let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image - let elephantTwoImage = Asset.Welcome.Illustration.elephantTwo.image - let lineDashTwoImage = Asset.Welcome.Illustration.lineDashTwo.image - - // let elephantOnAirplaneWithContrailImageView = Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image - - // draw elephantFourOnGrassWithTreeTwo - // elephantFourOnGrassWithTreeTwo.bottomY + 40 align to elephantThreeOnGrassImage.centerY - elephantFourOnGrassWithTreeTwoImage.draw(at: CGPoint(x: 0, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantFourOnGrassWithTreeTwoImage.size.height - 40)) - + } + + rightHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + // draw elephantThreeOnGrassWithTreeFour // elephantThreeOnGrassWithTreeFour.bottomY + 40 align to elephantThreeOnGrassImage.centerY elephantThreeOnGrassWithTreeFourImage.draw(at: CGPoint(x: width - elephantThreeOnGrassWithTreeFourImage.size.width, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantThreeOnGrassWithTreeFourImage.size.height - 40)) - - // draw elephantThreeOnGrass - elephantThreeOnGrassImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassImage.size.height)) - - // darw ineDashTwoImage - lineDashTwoImage.draw(at: CGPoint(x: 0.5 * elephantThreeOnGrassImage.size.width + 60, y: height - elephantThreeOnGrassImage.size.height - 50)) - - // draw elephantTwo.image - elephantTwoImage.draw(at: CGPoint(x: 0, y: height - elephantTwoImage.size.height - 125)) - - // draw elephantOnAirplaneWithContrailImageView - // elephantOnAirplaneWithContrailImageView.draw(at: CGPoint(x: 0, y: height - cloudBaseImage.size.height - 0.5 * elephantOnAirplaneWithContrailImageView.size.height)) } - return image + leftHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantFourOnGrassWithTreeTwo + // elephantFourOnGrassWithTreeTwo.bottomY + 40 align to elephantThreeOnGrassImage.centerY + elephantFourOnGrassWithTreeTwoImage.draw(at: CGPoint(x: 0, y: height - 0.5 * elephantThreeOnGrassImage.size.height - elephantFourOnGrassWithTreeTwoImage.size.height - 40)) + } + + centerHillImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantThreeOnGrass + elephantThreeOnGrassImage.draw(at: CGPoint(x: 0, y: height - elephantThreeOnGrassImage.size.height)) + } + + lineDashTwoImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // darw ineDashTwoImage + lineDashTwoImage.draw(at: CGPoint(x: 0.5 * elephantThreeOnGrassImage.size.width + 60, y: height - elephantThreeOnGrassImage.size.height - 50)) + } + + elephantTwoImageView.image = UIGraphicsImageRenderer(size: size).image { context in + // clear background + UIColor.clear.setFill() + context.fill(CGRect(origin: .zero, size: size)) + + // draw elephantTwo.image + elephantTwoImage.draw(at: CGPoint(x: 0, y: height - elephantTwoImage.size.height - 125)) + } } } diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 16592f328..e306aafe9 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -75,6 +75,25 @@ extension WelcomeViewController { ]) view.backgroundColor = .black welcomeIllustrationView.alpha = 0.9 + + welcomeIllustrationView.cloudBaseImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -5, maxX: 5, minY: -5, maxY: 5) + ) + welcomeIllustrationView.rightHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -12, maxY: 12) + ) + welcomeIllustrationView.leftHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -12, maxX: 12, minY: -20, maxY: 20) + ) + welcomeIllustrationView.centerHillImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -14, maxX: 14, minY: -30, maxY: 30) + ) + welcomeIllustrationView.lineDashTwoImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -25, maxX: 25, minY: -40, maxY: 40) + ) + welcomeIllustrationView.elephantTwoImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -30, maxY: 30) + ) } view.addSubview(logoImageView) @@ -122,12 +141,26 @@ extension WelcomeViewController { welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(welcomeIllustrationView.elephantOnAirplaneWithContrailImageView) NSLayoutConstraint.activate([ - welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor.constraint(equalTo: view.leftAnchor), + view.leftAnchor.constraint(equalTo: welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.leftAnchor, constant: 12), // add 12pt bleeding welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.bottomAnchor.constraint(equalTo: sloganLabel.topAnchor), // make a little bit large welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.widthAnchor.constraint(equalToConstant: 656 / traitCollection.displayScale * imageSizeScale), welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.heightAnchor.constraint(equalToConstant: 195 / traitCollection.displayScale * imageSizeScale), ]) + + welcomeIllustrationView.cloudFirstImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -30, maxX: 30, minY: -20, maxY: 10) + ) + welcomeIllustrationView.cloudSecondImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -10, maxX: 30, minY: -8, maxY: 10) + ) + welcomeIllustrationView.cloudThirdImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 10, minY: -6, maxY: 10) + ) + welcomeIllustrationView.elephantOnAirplaneWithContrailImageView.addMotionEffect( + UIInterpolatingMotionEffect.motionEffect(minX: -20, maxX: 12, minY: -20, maxY: 12) // maxX should not larger then the bleeding (12pt) + ) + view.bringSubviewToFront(logoImageView) view.bringSubviewToFront(sloganLabel) } diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 00d839b51..72ee1334d 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -40,12 +40,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension AppDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - #if DEBUG - return .all - #else - return UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait - #endif + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all } } From 80954b04925f67a936a43fb988e83f542df7d0c1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 15:51:16 +0800 Subject: [PATCH 018/400] feat: add Poll and PollOption entity to CoreDataStack --- .../CoreData.xcdatamodel/contents | 31 +++++- CoreDataStack/Entity/MastodonUser.swift | 1 + CoreDataStack/Entity/Poll.swift | 96 +++++++++++++++++++ CoreDataStack/Entity/PollOption.swift | 76 +++++++++++++++ CoreDataStack/Entity/Tag.swift | 11 ++- CoreDataStack/Entity/Toot.swift | 3 + Mastodon.xcodeproj/project.pbxproj | 10 +- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Generated/Assets.swift | 1 - ...meTimelineViewController+DebugAction.swift | 55 +++++++++++ .../CoreData/APIService+CoreData+Toot.swift | 12 ++- 11 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 CoreDataStack/Entity/Poll.swift create mode 100644 CoreDataStack/Entity/PollOption.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3fe5fe16e..1ef9f929d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -83,6 +83,7 @@ + @@ -93,6 +94,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -131,6 +153,7 @@ + @@ -138,14 +161,16 @@ + - + - - + + + \ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index bcbfe5d26..8ecf66282 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -37,6 +37,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var reblogged: Set? @NSManaged public private(set) var muted: Set? @NSManaged public private(set) var bookmarked: Set? + @NSManaged public private(set) var votePollOptions: Set? } diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift new file mode 100644 index 000000000..1e8b2528f --- /dev/null +++ b/CoreDataStack/Entity/Poll.swift @@ -0,0 +1,96 @@ +// +// Poll.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class Poll: NSManagedObject { + public typealias ID = String + + @NSManaged public private(set) var id: ID + @NSManaged public private(set) var expiresAt: Date? + @NSManaged public private(set) var expired: Bool + @NSManaged public private(set) var multiple: Bool + @NSManaged public private(set) var votesCount: NSNumber + @NSManaged public private(set) var votersCount: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // one-to-one relationship + @NSManaged public private(set) var toot: Toot + + // one-to-many relationship + @NSManaged public private(set) var options: Set +} + +extension Poll { + + public override func awakeFromInsert() { + super.awakeFromInsert() + createdAt = Date() + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + options: [PollOption] + ) -> Poll { + let poll: Poll = context.insertObject() + + poll.id = property.id + poll.expiresAt = property.expiresAt + poll.expired = property.expired + poll.multiple = property.multiple + poll.votesCount = property.votesCount + poll.votersCount = property.votersCount + + poll.updatedAt = property.networkDate + poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) + + return poll + } + +} + +extension Poll { + public struct Property { + public let id: ID + public let expiresAt: Date? + public let expired: Bool + public let multiple: Bool + public let votesCount: NSNumber + public let votersCount: NSNumber? + + public let networkDate: Date + + public init( + id: Poll.ID, + expiresAt: Date?, + expired: Bool, + multiple: Bool, + votesCount: Int, + votersCount: Int?, + networkDate: Date + ) { + self.id = id + self.expiresAt = expiresAt + self.expired = expired + self.multiple = multiple + self.votesCount = NSNumber(value: votesCount) + self.votersCount = votersCount.flatMap { NSNumber(value: $0) } + self.networkDate = networkDate + } + } +} + +extension Poll: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Poll.createdAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift new file mode 100644 index 000000000..f0d3219d8 --- /dev/null +++ b/CoreDataStack/Entity/PollOption.swift @@ -0,0 +1,76 @@ +// +// PollOption.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +public final class PollOption: NSManagedObject { + @NSManaged public private(set) var index: NSNumber + @NSManaged public private(set) var title: String + @NSManaged public private(set) var votesCount: NSNumber? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + @NSManaged public private(set) var poll: Poll + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? +} + +extension PollOption { + + public override func awakeFromInsert() { + super.awakeFromInsert() + createdAt = Date() + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property, + votedBy: MastodonUser? + ) -> PollOption { + let option: PollOption = context.insertObject() + + option.index = property.index + option.title = property.title + option.votesCount = property.votesCount + option.updatedAt = property.networkDate + + if let votedBy = votedBy { + option.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + } + + return option + } + +} + +extension PollOption { + public struct Property { + public let index: NSNumber + public let title: String + public let votesCount: NSNumber? + + public let networkDate: Date + + public init(index: Int, title: String, votesCount: Int?, networkDate: Date) { + self.index = NSNumber(value: index) + self.title = title + self.votesCount = votesCount.flatMap { NSNumber(value: $0) } + self.networkDate = networkDate + } + } +} + +extension PollOption: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PollOption.createdAt, ascending: false)] + } +} diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index b5d8be688..3f5d2bcac 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -23,13 +23,14 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var histories: Set? } -public extension Tag { - override func awakeFromInsert() { +extension Tag { + public override func awakeFromInsert() { super.awakeFromInsert() identifier = UUID() } + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Tag { @@ -43,8 +44,8 @@ public extension Tag { } } -public extension Tag { - struct Property { +extension Tag { + public struct Property { public let name: String public let url: String public let histories: [History]? diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index b37609a21..c5fcf4869 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -48,6 +48,7 @@ public final class Toot: NSManagedObject { // one-to-one relastionship @NSManaged public private(set) var pinnedBy: MastodonUser? + @NSManaged public private(set) var poll: Poll? // one-to-many relationship @NSManaged public private(set) var reblogFrom: Set? @@ -69,6 +70,7 @@ public extension Toot { author: MastodonUser, reblog: Toot?, application: Application?, + poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, tags: [Tag]?, @@ -109,6 +111,7 @@ public extension Toot { toot.reblog = reblog toot.pinnedBy = pinnedBy + toot.poll = poll if let mentions = mentions { toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0415385bb..cd08985c5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -107,6 +107,8 @@ DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; + DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -149,7 +151,6 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */; }; - DB98334725C8056600AD9700 /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -328,6 +329,8 @@ DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; + DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; + DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = ""; }; DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; @@ -371,7 +374,6 @@ DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.swift; sourceTree = ""; }; - DB98334625C8056600AD9700 /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -945,6 +947,8 @@ DB45FAEC25CA7A9A005A8AC7 /* MastodonAuthentication.swift */, 2DA7D05625CA693F00804E11 /* Application.swift */, DB9D6C2D25E504AC0051B173 /* Attachment.swift */, + DB4481AC25EE155900BEFB67 /* Poll.swift */, + DB4481B225EE16D000BEFB67 /* PollOption.swift */, ); path = Entity; sourceTree = ""; @@ -1590,8 +1594,10 @@ DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, + DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, DB89BA2725C110B4008580ED /* Toot.swift in Sources */, 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, + DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index b1a7a744c..bc78dfa4b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -17,12 +17,12 @@ Mastodon - Release.xcscheme_^#shared#^_ orderHint - 1 + 2 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 SuppressBuildableAutocreation diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index a4f4e0803..08507ed9d 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -71,7 +71,6 @@ internal enum Asset { internal enum Welcome { internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") - internal static let welcomeLogo = ImageAsset(name: "Welcome/welcome.logo") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 69f0347e0..9c3af1f73 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -7,6 +7,8 @@ import os.log import UIKit +import CoreData +import CoreDataStack #if DEBUG extension HomeTimelineViewController { @@ -17,6 +19,7 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ + dropMenu, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) @@ -29,10 +32,62 @@ extension HomeTimelineViewController { ) return menu } + + var dropMenu: UIMenu { + return UIMenu( + title: "Drop…", + image: UIImage(systemName: "minus.circle"), + identifier: nil, + options: [], + children: [50, 100, 150, 200, 250, 300].map { count in + UIAction(title: "Drop Recent \(count) Tweets", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentTweetsAction(action, count: count) + }) + } + ) + } } extension HomeTimelineViewController { + @objc private func dropRecentTweetsAction(_ sender: UIAction, count: Int) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + + let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + switch item { + case .homeTimelineIndex(let objectID, _): return objectID + default: return nil + } + } + var droppingTootObjectIDs: [NSManagedObjectID] = [] + context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingObjectIDs { + guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } + droppingTootObjectIDs.append(homeTimelineIndex.toot.objectID) + self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingTootObjectIDs { + guard let toot = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(toot) + } + } + case .failure(let error): + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index bbf814e66..eeb2afa2a 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -51,6 +51,14 @@ extension APIService.CoreData { let application = entity.application.flatMap { app -> Application? in Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) } + let poll = entity.poll.flatMap { poll -> Poll in + let options = poll.options.enumerated().map { i, option -> PollOption in + let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy) + } + let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), options: options) + return object + } let metions = entity.mentions?.compactMap { mention -> Mention in Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) } @@ -83,6 +91,7 @@ extension APIService.CoreData { author: mastodonUser, reblog: reblog, application: application, + poll: poll, mentions: metions, emojis: emojis, tags: tags, @@ -128,9 +137,6 @@ extension APIService.CoreData { } } - - - // set updateAt toot.didUpdate(at: networkDate) From ea511c153fa7fe0ab081dc54ed3f26e7d18fb2e6 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 16:19:20 +0800 Subject: [PATCH 019/400] chore: set user avatar use PhotoUI --- Mastodon.xcodeproj/project.pbxproj | 21 +++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++++ Mastodon/Generated/Strings.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- ...astodonRegisterViewController+Avatar.swift | 47 +++++++++++++++++++ .../MastodonRegisterViewController.swift | 5 ++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9b5894130..2a7c70420 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -72,6 +72,8 @@ 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; + 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; @@ -279,6 +281,7 @@ 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -395,6 +398,7 @@ DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, @@ -1079,6 +1083,7 @@ isa = PBXGroup; children = ( DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */, + 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */, DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */, ); path = Register; @@ -1124,6 +1129,7 @@ DB0140BC25C40D7500F9F3CF /* CommonOSLog */, DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, + 2D939AC725EE14620076FA61 /* CropViewController */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1252,6 +1258,7 @@ DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1493,6 +1500,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, @@ -2099,6 +2107,14 @@ minimumVersion = 3.1.0; }; }; + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TimOliver/TOCropViewController.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.0; + }; + }; DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/CommonOSLog"; @@ -2141,6 +2157,11 @@ package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; productName = AlamofireNetworkActivityIndicator; }; + 2D939AC725EE14620076FA61 /* CropViewController */ = { + isa = XCSwiftPackageProductDependency; + package = 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */; + productName = CropViewController; + }; 5D526FE125BE9AC400460CB9 /* MastodonSDK */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDK; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 456a6e967..543a09da9 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -90,6 +90,15 @@ "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84", "version": "1.7.1" } + }, + { + "package": "TOCropViewController", + "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", + "state": { + "branch": null, + "revision": "dad97167bf1be16aeecd109130900995dd01c515", + "version": "2.6.0" + } } ] }, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8e93c804e..fedb437ac 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -124,7 +124,7 @@ internal enum L10n { internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") /// Username must only contain alphanumeric characters and underscores internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") - /// username is too long ( can't be longer than 30 characters) + /// username is too long (can't be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 191ec0daf..0bb8b8cb0 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -42,7 +42,7 @@ "Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; "Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; "Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long ( can't be longer than 30 characters)"; +"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift new file mode 100644 index 000000000..b25b402df --- /dev/null +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -0,0 +1,47 @@ +// +// MastodonRegisterViewController+Avatar.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/2. +// + +import Foundation +import UIKit +import CropViewController +import PhotosUI +extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate, UINavigationControllerDelegate{ + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + if let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) { + + itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self, let image = image as? UIImage else { return } + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + self.image = image + picker.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } + } else { + picker.dismiss(animated: true, completion: { + }) + } + } + + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + self.image = image + self.photoButton.setImage(image, for: .normal) + cropViewController.dismiss(animated: true, completion: nil) + } + + @objc func avatarButtonPressed(_ sender: UIButton) { + var configuration = PHPickerConfiguration() + configuration.filter = .images + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + self.present(imagePicker, animated: true, completion: nil) + } +} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 9931a8b19..cc64be1f0 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -17,6 +17,9 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + // avater image + var image: UIImage? + var viewModel: MastodonRegisterViewModel! let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -56,6 +59,8 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O button.backgroundColor = .white button.layer.cornerRadius = 45 button.clipsToBounds = true + + button.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) return button }() From 8b63c2fda133eedc8da209c410974b1f7cb2a4d0 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 16:27:11 +0800 Subject: [PATCH 020/400] feat: add PollSection and PollItem for diffable data source --- Mastodon.xcodeproj/project.pbxproj | 14 +++- Mastodon/Diffiable/Item/PollItem.swift | 49 ++++++++++++ Mastodon/Diffiable/Section/PollSection.swift | 25 ++++++ .../Diffiable/Section/StatusSection.swift | 3 + Mastodon/Extension/UITableView.swift | 55 +++++++++++++ ...meTimelineViewController+DebugAction.swift | 77 ++++++++++++++++++- .../HomeTimeline/HomeTimelineViewModel.swift | 6 +- .../Scene/Share/View/Content/StatusView.swift | 14 +++- .../TableviewCell/StatusTableViewCell.swift | 1 + 9 files changed, 235 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Diffiable/Item/PollItem.swift create mode 100644 Mastodon/Diffiable/Section/PollSection.swift create mode 100644 Mastodon/Extension/UITableView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cd08985c5..e1283e374 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -109,6 +109,9 @@ DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; + DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481C525EE2ADA00BEFB67 /* PollSection.swift */; }; + DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */; }; DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */; }; DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */; }; DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */; }; @@ -331,6 +334,9 @@ DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = ""; }; + DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollSection.swift; sourceTree = ""; }; + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollItem.swift; sourceTree = ""; }; DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponderService.swift; sourceTree = ""; }; DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIBarButtonItem.swift; sourceTree = ""; }; @@ -634,8 +640,8 @@ 2D76319C25C151DE00929FB9 /* Diffiable */ = { isa = PBXGroup; children = ( - 2D7631B125C159E700929FB9 /* Item */, 2D76319D25C151F600929FB9 /* Section */, + 2D7631B125C159E700929FB9 /* Item */, ); path = Diffiable; sourceTree = ""; @@ -644,6 +650,7 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, ); path = Section; sourceTree = ""; @@ -686,6 +693,7 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, ); path = Item; sourceTree = ""; @@ -1021,6 +1029,7 @@ DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, + DB4481B825EE289600BEFB67 /* UITableView.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, @@ -1476,7 +1485,9 @@ DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, + DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, @@ -1501,6 +1512,7 @@ 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift new file mode 100644 index 000000000..7a13df413 --- /dev/null +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -0,0 +1,49 @@ +// +// PollItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import Foundation +import CoreData + +enum PollItem { + case pollOpion(objectID: NSManagedObjectID, attribute: Attribute) +} + + +extension PollItem { + class Attribute: Hashable { + var voted: Bool = false + + static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { + return lhs.voted == rhs.voted + } + + func hash(into hasher: inout Hasher) { + hasher.combine(voted) + } + } +} + +extension PollItem: Equatable { + static func == (lhs: PollItem, rhs: PollItem) -> Bool { + switch (lhs, rhs) { + case (.pollOpion(let objectIDLeft, _), .pollOpion(let objectIDRight, _)): + return objectIDLeft == objectIDRight + default: + return false + } + } +} + + +extension PollItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .pollOpion(let objectID, _): + hasher.combine(objectID) + } + } +} diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift new file mode 100644 index 000000000..9b175c3f9 --- /dev/null +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -0,0 +1,25 @@ +// +// PollSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-2. +// + +import UIKit +import CoreData +import CoreDataStack + +enum PollSection { + case main +} + +extension PollSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext + ) -> UITableViewDiffableDataSource { + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + return nil + } + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4fac88b4c..89bf1c6e1 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -153,6 +153,9 @@ extension StatusSection { let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + + // set poll + // toolbar let replyCountTitle: String = { diff --git a/Mastodon/Extension/UITableView.swift b/Mastodon/Extension/UITableView.swift new file mode 100644 index 000000000..22ae6c0b5 --- /dev/null +++ b/Mastodon/Extension/UITableView.swift @@ -0,0 +1,55 @@ +// +// UITableView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-3-2. +// + +import UIKit + +extension UITableView { + + // static let groupedTableViewPaddingHeaderViewHeight: CGFloat = 16 + // static var groupedTableViewPaddingHeaderView: UIView { + // return UIView(frame: CGRect(x: 0, y: 0, width: 100, height: groupedTableViewPaddingHeaderViewHeight)) + // } + +} + +extension UITableView { + + func deselectRow(with transitionCoordinator: UIViewControllerTransitionCoordinator?, animated: Bool) { + guard let indexPathForSelectedRow = indexPathForSelectedRow else { return } + + guard let transitionCoordinator = transitionCoordinator else { + deselectRow(at: indexPathForSelectedRow, animated: animated) + return + } + + transitionCoordinator.animate(alongsideTransition: { _ in + self.deselectRow(at: indexPathForSelectedRow, animated: animated) + }, completion: { context in + if context.isCancelled { + self.selectRow(at: indexPathForSelectedRow, animated: animated, scrollPosition: .none) + } + }) + } + + func blinkRow(at indexPath: IndexPath) { + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1) { [weak self] in + guard let self = self else { return } + guard let cell = self.cellForRow(at: indexPath) else { return } + let backgroundColor = cell.backgroundColor + + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = Asset.Colors.Label.highlight.color.withAlphaComponent(0.5) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UIView.animate(withDuration: 0.3) { + cell.backgroundColor = backgroundColor + } + } + } + } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 9c3af1f73..bb6d6dae4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -19,6 +19,7 @@ extension HomeTimelineViewController { identifier: nil, options: .displayInline, children: [ + moveMenu, dropMenu, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } @@ -33,6 +34,41 @@ extension HomeTimelineViewController { return menu } + var moveMenu: UIMenu { + return UIMenu( + title: "Move to…", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToTopGapAction(action) + }), + UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstPollToot(action) + }), +// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstReplyToot(action) +// }), +// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstReplyReblog(action) +// }), +// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstVideoToot(action) +// }), +// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in +// guard let self = self else { return } +// self.moveToFirstGIFToot(action) +// }), + ] + ) + } + var dropMenu: UIMenu { return UIMenu( title: "Drop…", @@ -40,9 +76,9 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Tweets", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.dropRecentTweetsAction(action, count: count) + self.dropRecentTootsAction(action, count: count) }) } ) @@ -51,7 +87,42 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { - @objc private func dropRecentTweetsAction(_ sender: UIAction, count: Int) { + @objc private func moveToTopGapAction(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeMiddleLoader: return true + default: return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + } + } + + @objc private func moveToFirstPollToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.poll != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found poll toot") + } + } + + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index dd5ee97b1..44457839a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -110,10 +110,10 @@ final class HomeTimelineViewModel: NSObject { context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } - guard let twitterAuthentication = activeMastodonAuthentication else { return } - let activeTwitterUserID = twitterAuthentication.userID + guard let mastodonAuthentication = activeMastodonAuthentication else { return } + let activeMastodonUserID = mastodonAuthentication.userID let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - HomeTimelineIndex.predicate(userID: activeTwitterUserID), + HomeTimelineIndex.predicate(userID: activeMastodonUserID), HomeTimelineIndex.notDeleted() ]) self.timelinePredicate.value = predicate diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index be754ed86..abaa38f33 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -23,6 +23,7 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false + var statusPollTableViewDataSource: UITableViewDiffableDataSource? let headerContainerStackView = UIStackView() @@ -101,6 +102,13 @@ final class StatusView: UIView { }() let statusMosaicImageView = MosaicImageViewContainer() + let statusPollTableView: UITableView = { + let tableView = UITableView() + tableView.register(PollTableViewCell.self, forCellReuseIdentifier: String(describing: PollTableViewCell.self)) + tableView.isScrollEnabled = false + return tableView + }() + // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { let imageView = UIImageView() @@ -222,7 +230,7 @@ extension StatusView { subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) - // status container: [status | image / video | audio] + // status container: [status | image / video | audio | poll] containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 @@ -258,7 +266,7 @@ extension StatusView { statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) statusContainerStackView.addArrangedSubview(statusMosaicImageView) - + statusContainerStackView.addArrangedSubview(statusPollTableView) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -266,6 +274,8 @@ extension StatusView { headerContainerStackView.isHidden = true statusMosaicImageView.isHidden = true + statusPollTableView.isHidden = true + contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 572f23e01..eb1a015b4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -33,6 +33,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false + statusView.statusPollTableView.dataSource = nil statusView.cleanUpContentWarning() disposeBag.removeAll() observations.removeAll() From 211f5dd0e459a994a5012ea899b2d8c998e314a3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 16:27:58 +0800 Subject: [PATCH 021/400] chore: code format --- ...astodonRegisterViewController+Avatar.swift | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index b25b402df..67bd2629e 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -5,31 +5,30 @@ // Created by sxiaojian on 2021/3/2. // -import Foundation -import UIKit import CropViewController +import Foundation import PhotosUI -extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate, UINavigationControllerDelegate{ +import UIKit + +extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - if let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) { - - itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in - guard let self = self, let image = image as? UIImage else { return } - DispatchQueue.main.async { - let cropController = CropViewController(croppingStyle: .default, image: image) - cropController.delegate = self - self.image = image - picker.dismiss(animated: true, completion: { - self.present(cropController, animated: true, completion: nil) - }) - } + guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { + picker.dismiss(animated: true, completion: {}) + return + } + itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in + guard let self = self, let image = image as? UIImage else { return } + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + self.image = image + picker.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) } - } else { - picker.dismiss(animated: true, completion: { - }) } } - + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { self.image = image self.photoButton.setImage(image, for: .normal) @@ -39,7 +38,7 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi @objc func avatarButtonPressed(_ sender: UIButton) { var configuration = PHPickerConfiguration() configuration.filter = .images - + let imagePicker = PHPickerViewController(configuration: configuration) imagePicker.delegate = self self.present(imagePicker, animated: true, completion: nil) From eb2057d14e840266401f4c3d17f1f8e3c2dc63f4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 16:39:43 +0800 Subject: [PATCH 022/400] chore: make sure to crop the image into a square --- .../Register/MastodonRegisterViewController+Avatar.swift | 5 +++-- .../Onboarding/Register/MastodonRegisterViewController.swift | 3 --- .../Onboarding/Register/MastodonRegisterViewModel.swift | 1 + 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 67bd2629e..9b3937122 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -21,7 +21,8 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self - self.image = image + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioLockEnabled = true picker.dismiss(animated: true, completion: { self.present(cropController, animated: true, completion: nil) }) @@ -30,7 +31,7 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi } public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - self.image = image + self.viewModel.avatarImage.value = image self.photoButton.setImage(image, for: .normal) cropViewController.dismiss(animated: true, completion: nil) } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index cc64be1f0..81dc16f75 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -17,9 +17,6 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - // avater image - var image: UIImage? - var viewModel: MastodonRegisterViewModel! let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index a32e5d040..9e244c2de 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -24,6 +24,7 @@ final class MastodonRegisterViewModel { let email = CurrentValueSubject("") let password = CurrentValueSubject("") let reason = CurrentValueSubject("") + let avatarImage = CurrentValueSubject(nil) // output let approvalRequired: Bool From f16b4ed1cbdb682bb11544042c4889882c86c138 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 17:24:04 +0800 Subject: [PATCH 023/400] chore: update password hint, handle error when use PhotosUI --- Localization/app.json | 3 +-- Mastodon/Generated/Strings.swift | 6 ++--- .../Resources/en.lproj/Localizable.strings | 3 +-- ...astodonRegisterViewController+Avatar.swift | 24 +++++++++++++------ .../MastodonRegisterViewController.swift | 10 ++++++++ .../Register/MastodonRegisterViewModel.swift | 5 +--- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 3a45922a5..43cdf01ad 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -108,8 +108,7 @@ }, "password": { "placeholder": "password", - "prompt": "Your password needs at least:", - "prompt_eight_characters": "Eight characters" + "hint": "Your password needs at least Eight characters" }, "invite": { "registration_user_invite_request": "Why do you want to join?" diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index fedb437ac..dfd69f0c6 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -192,12 +192,10 @@ internal enum L10n { internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest") } internal enum Password { + /// Your password needs at least Eight characters + internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint") /// password internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder") - /// Your password needs at least: - internal static let prompt = L10n.tr("Localizable", "Scene.Register.Input.Password.Prompt") - /// Eight characters - internal static let promptEightCharacters = L10n.tr("Localizable", "Scene.Register.Input.Password.PromptEightCharacters") } internal enum Username { /// This username is taken. diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 0bb8b8cb0..d333460e9 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -61,9 +61,8 @@ tap the link to confirm your account."; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; +"Scene.Register.Input.Password.Hint" = "Your password needs at least Eight characters"; "Scene.Register.Input.Password.Placeholder" = "password"; -"Scene.Register.Input.Password.Prompt" = "Your password needs at least:"; -"Scene.Register.Input.Password.PromptEightCharacters" = "Eight characters"; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Success" = "Success"; diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 9b3937122..bb4e3f3eb 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -16,12 +16,27 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi picker.dismiss(animated: true, completion: {}) return } - itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in - guard let self = self, let image = image as? UIImage else { return } + itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self else { return } + guard let image = image as? UIImage else { + guard let error = error else { return } + let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + DispatchQueue.main.async { + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + return + } DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true cropController.aspectRatioLockEnabled = true picker.dismiss(animated: true, completion: { self.present(cropController, animated: true, completion: nil) @@ -37,11 +52,6 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi } @objc func avatarButtonPressed(_ sender: UIButton) { - var configuration = PHPickerConfiguration() - configuration.filter = .images - - let imagePicker = PHPickerViewController(configuration: configuration) - imagePicker.delegate = self self.present(imagePicker, animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 81dc16f75..a5e75e0fa 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -10,6 +10,7 @@ import MastodonSDK import os.log import UIKit import UITextField_Shake +import PhotosUI final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { var disposeBag = Set() @@ -19,6 +20,15 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O var viewModel: MastodonRegisterViewModel! + lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer let scrollView: UIScrollView = { diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 9e244c2de..f0c11849a 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -161,11 +161,8 @@ extension MastodonRegisterViewModel { let falseColor = UIColor.clear let attributeString = NSMutableAttributedString() - let start = NSAttributedString(string: "Your password needs at least:", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(start) - attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) - let eightCharactersDescription = NSAttributedString(string: " Eight characters\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) attributeString.append(eightCharactersDescription) return attributeString From aea2ddc078f0ec8f7929a7997161e947e13a7b88 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 19:10:45 +0800 Subject: [PATCH 024/400] feat: make toot poll display --- .../CoreData.xcdatamodel/contents | 6 ++-- Mastodon.xcodeproj/project.pbxproj | 8 ++--- Mastodon/Diffiable/Item/PollItem.swift | 12 ++++--- Mastodon/Diffiable/Section/PollSection.swift | 22 +++++++++++-- .../Diffiable/Section/StatusSection.swift | 26 ++++++++++++++- ...meTimelineViewController+DebugAction.swift | 4 +++ .../Scene/Share/View/Content/StatusView.swift | 31 +++++++++++++++--- ...ll.swift => PollOptionTableViewCell.swift} | 32 +++++++++++++------ .../TableviewCell/StatusTableViewCell.swift | 1 - 9 files changed, 112 insertions(+), 30 deletions(-) rename Mastodon/Scene/Share/View/TableviewCell/{PollTableViewCell.swift => PollOptionTableViewCell.swift} (86%) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 1ef9f929d..96ec6971d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -150,7 +150,7 @@ - + @@ -168,9 +168,9 @@ - - + + \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e1283e374..7509264bb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -153,7 +153,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; - DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */; }; + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -379,7 +379,7 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; - DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableViewCell.swift; sourceTree = ""; }; + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -684,7 +684,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, - DB92CF7125E7BB98002C1017 /* PollTableViewCell.swift */, + DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; sourceTree = ""; @@ -1469,7 +1469,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, - DB92CF7225E7BB98002C1017 /* PollTableViewCell.swift in Sources */, + DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 7a13df413..1ae8f34e3 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -9,7 +9,7 @@ import Foundation import CoreData enum PollItem { - case pollOpion(objectID: NSManagedObjectID, attribute: Attribute) + case opion(objectID: NSManagedObjectID, attribute: Attribute) } @@ -17,6 +17,10 @@ extension PollItem { class Attribute: Hashable { var voted: Bool = false + init(voted: Bool = false) { + self.voted = voted + } + static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { return lhs.voted == rhs.voted } @@ -30,10 +34,8 @@ extension PollItem { extension PollItem: Equatable { static func == (lhs: PollItem, rhs: PollItem) -> Bool { switch (lhs, rhs) { - case (.pollOpion(let objectIDLeft, _), .pollOpion(let objectIDRight, _)): + case (.opion(let objectIDLeft, _), .opion(let objectIDRight, _)): return objectIDLeft == objectIDRight - default: - return false } } } @@ -42,7 +44,7 @@ extension PollItem: Equatable { extension PollItem: Hashable { func hash(into hasher: inout Hasher) { switch self { - case .pollOpion(let objectID, _): + case .opion(let objectID, _): hasher.combine(objectID) } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 9b175c3f9..b48231dbf 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -9,7 +9,7 @@ import UIKit import CoreData import CoreDataStack -enum PollSection { +enum PollSection: Equatable, Hashable { case main } @@ -19,7 +19,25 @@ extension PollSection { managedObjectContext: NSManagedObjectContext ) -> UITableViewDiffableDataSource { return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in - return nil + switch item { + case .opion(let objectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell + managedObjectContext.performAndWait { + let option = managedObjectContext.object(with: objectID) as! PollOption + PollSection.configure(cell: cell, pollOption: option, itemAttribute: attribute) + } + return cell + } } } } + +extension PollSection { + static func configure( + cell: PollOptionTableViewCell, + pollOption: PollOption, + itemAttribute: PollItem.Attribute + ) { + cell.optionLabel.text = pollOption.title + } +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 89bf1c6e1..b8838869c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -155,7 +155,31 @@ extension StatusSection { cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll - + if let poll = (toot.reblog ?? toot).poll { + cell.statusView.statusPollTableView.isHidden = false + + let managedObjectContext = toot.managedObjectContext! + cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.statusPollTableView, + managedObjectContext: managedObjectContext + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let pollItems = poll.options + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .map { option -> PollItem in + let isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let attribute = PollItem.Attribute(voted: isVoted) + let option = PollItem.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + // cell.statusView.statusPollTableView.layoutIfNeeded() + } else { + cell.statusView.statusPollTableView.isHidden = true + } // toolbar let replyCountTitle: String = { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index bb6d6dae4..0937e1fb4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -152,6 +152,10 @@ extension HomeTimelineViewController { self.context.apiService.backgroundManagedObjectContext.delete(toot) } } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) case .failure(let error): assertionFailure(error.localizedDescription) } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index abaa38f33..77cc851c1 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -17,6 +17,8 @@ protocol StatusViewDelegate: class { final class StatusView: UIView { + var statusPollTableViewHeightObservation: NSKeyValueObservation? + static let avatarImageSize = CGSize(width: 42, height: 42) static let avatarImageCornerRadius: CGFloat = 4 static let contentWarningBlurRadius: CGFloat = 12 @@ -24,6 +26,7 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false var statusPollTableViewDataSource: UITableViewDiffableDataSource? + var statusPollTableViewHeightLaoutConstraint: NSLayoutConstraint! let headerContainerStackView = UIStackView() @@ -103,9 +106,11 @@ final class StatusView: UIView { let statusMosaicImageView = MosaicImageViewContainer() let statusPollTableView: UITableView = { - let tableView = UITableView() - tableView.register(PollTableViewCell.self, forCellReuseIdentifier: String(describing: PollTableViewCell.self)) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) tableView.isScrollEnabled = false + tableView.separatorStyle = .none + tableView.backgroundColor = .clear return tableView }() @@ -144,6 +149,10 @@ final class StatusView: UIView { drawContentWarningImageView() } } + + deinit { + statusPollTableViewHeightObservation = nil + } } @@ -265,8 +274,23 @@ extension StatusView { ]) statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) + statusContainerStackView.addArrangedSubview(statusMosaicImageView) + statusPollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(statusPollTableView) + statusPollTableViewHeightLaoutConstraint = statusPollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + NSLayoutConstraint.activate([ + statusPollTableViewHeightLaoutConstraint, + ]) + + statusPollTableViewHeightObservation = statusPollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in + guard let self = self else { return } + guard self.statusPollTableView.contentSize.height != .zero else { + self.statusPollTableViewHeightLaoutConstraint.constant = 44 + return + } + self.statusPollTableViewHeightLaoutConstraint.constant = self.statusPollTableView.contentSize.height + }) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -322,14 +346,13 @@ extension StatusView { } } +// MARK: - AvatarConfigurableView extension StatusView: AvatarConfigurableView { static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } static var configurableAvatarImageCornerRadius: CGFloat { return 4 } var configurableAvatarImageView: UIImageView? { return nil } var configurableAvatarButton: UIButton? { return avatarButton } var configurableVerifiedBadgeImageView: UIImageView? { nil } - - } #if canImport(SwiftUI) && DEBUG diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift similarity index 86% rename from Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift rename to Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index d41fd7428..5372380bd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -1,5 +1,5 @@ // -// PollTableViewCell.swift +// PollOptionTableViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-2-25. @@ -7,8 +7,11 @@ import UIKit -final class PollTableViewCell: UITableViewCell { +final class PollOptionTableViewCell: UITableViewCell { + static let height: CGFloat = optionHeight + 2 * verticalMargin + static let optionHeight: CGFloat = 44 + static let verticalMargin: CGFloat = 5 static let checkmarkImageSize = CGSize(width: 26, height: 26) let roundedBackgroundView = UIView() @@ -57,9 +60,11 @@ final class PollTableViewCell: UITableViewCell { } -extension PollTableViewCell { +extension PollOptionTableViewCell { private func _init() { + selectionStyle = .none + backgroundColor = .clear roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false @@ -69,6 +74,7 @@ extension PollTableViewCell { roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), ]) checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false @@ -77,8 +83,8 @@ extension PollTableViewCell { checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), - checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), ]) checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false @@ -104,6 +110,8 @@ extension PollTableViewCell { roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), ]) + optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) configureCheckmark(state: .none) } @@ -111,8 +119,12 @@ extension PollTableViewCell { override func layoutSubviews() { super.layoutSubviews() + updateCornerRadius() + } + + private func updateCornerRadius() { roundedBackgroundView.layer.masksToBounds = true - roundedBackgroundView.layer.cornerRadius = roundedBackgroundView.bounds.height * 0.5 + roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5 roundedBackgroundView.layer.cornerCurve = .circular checkmarkBackgroundView.layer.masksToBounds = true @@ -122,7 +134,7 @@ extension PollTableViewCell { } -extension PollTableViewCell { +extension PollOptionTableViewCell { enum CheckmarkState { case none @@ -168,17 +180,17 @@ struct PollTableViewCell_Previews: PreviewProvider { static var controls: some View { Group { UIViewPreview() { - PollTableViewCell() + PollOptionTableViewCell() } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { - let cell = PollTableViewCell() + let cell = PollOptionTableViewCell() cell.configureCheckmark(state: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { - let cell = PollTableViewCell() + let cell = PollOptionTableViewCell() cell.configureCheckmark(state: .on) return cell } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index eb1a015b4..900094c57 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -10,7 +10,6 @@ import UIKit import AVKit import Combine - protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) From 376cb3d58aee1190966c485a2f76d4620682f8dc Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 2 Mar 2021 19:33:33 +0800 Subject: [PATCH 025/400] feat: display toot poll status --- Localization/app.json | 9 ++- .../Diffiable/Section/StatusSection.swift | 19 +++--- Mastodon/Generated/Strings.swift | 18 +++++- ...er+TimelinePostTableViewCellDelegate.swift | 4 +- .../Resources/en.lproj/Localizable.strings | 5 +- .../Scene/Share/View/Content/StatusView.swift | 58 ++++++++++++++----- .../TableviewCell/StatusTableViewCell.swift | 4 +- 7 files changed, 87 insertions(+), 30 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 3a45922a5..7b118b34d 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -58,7 +58,14 @@ "user_boosted": "%s boosted", "show_post": "Show Post", "status_content_warning": "content warning", - "media_content_warning": "Tap to reveal that may be sensitive" + "media_content_warning": "Tap to reveal that may be sensitive", + "poll": { + "vote_count": { + "single": "%d vote", + "multiple": "%d votes", + }, + "time_left": "%s left" + } }, "timeline": { "load_more": "Load More" diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b8838869c..2fd87f4d1 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -132,14 +132,14 @@ extension StatusSection { }() if mosiacImageViewModel.metas.count == 1 { let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageView.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) imageView.af.setImage( withURL: meta.url, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) } else { - let imageViews = cell.statusView.statusMosaicImageView.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) for (i, imageView) in imageViews.enumerated() { let meta = mosiacImageViewModel.metas[i] imageView.af.setImage( @@ -149,18 +149,19 @@ extension StatusSection { ) } } - cell.statusView.statusMosaicImageView.isHidden = mosiacImageViewModel.metas.isEmpty + cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll if let poll = (toot.reblog ?? toot).poll { - cell.statusView.statusPollTableView.isHidden = false + cell.statusView.pollTableView.isHidden = false + cell.statusView.pollStatusStackView.isHidden = false let managedObjectContext = toot.managedObjectContext! cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( - for: cell.statusView.statusPollTableView, + for: cell.statusView.pollTableView, managedObjectContext: managedObjectContext ) @@ -176,9 +177,9 @@ extension StatusSection { } snapshot.appendItems(pollItems, toSection: .main) cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - // cell.statusView.statusPollTableView.layoutIfNeeded() } else { - cell.statusView.statusPollTableView.isHidden = true + cell.statusView.pollTableView.isHidden = true + cell.statusView.pollStatusStackView.isHidden = true } // toolbar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8e93c804e..c049f4fca 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -68,6 +68,22 @@ internal enum L10n { internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } + internal enum Poll { + /// %@ left + internal static func timeLeft(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) + } + internal enum VoteCount { + /// %d votes + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Multiple", p1) + } + /// %d vote + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Single", p1) + } + } + } } internal enum Timeline { /// Load More @@ -124,7 +140,7 @@ internal enum L10n { internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") /// Username must only contain alphanumeric characters and underscores internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") - /// username is too long ( can't be longer than 30 characters) + /// username is too long (can't be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift index 336434ff0..4679969e2 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift @@ -69,8 +69,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { var snapshot = diffableDataSource.snapshot() snapshot.reloadItems([item]) UIView.animate(withDuration: 0.33) { - cell.statusView.statusMosaicImageView.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageView.vibrancyVisualEffectView.alpha = 0.0 + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 } completion: { _ in diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 191ec0daf..9b1dfdf7a 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -17,6 +17,9 @@ "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; @@ -42,7 +45,7 @@ "Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; "Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; "Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long ( can't be longer than 30 characters)"; +"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 77cc851c1..702d9c7e4 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -103,9 +103,9 @@ final class StatusView: UIView { button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) return button }() - let statusMosaicImageView = MosaicImageViewContainer() + let statusMosaicImageViewContainer = MosaicImageViewContainer() - let statusPollTableView: UITableView = { + let pollTableView: UITableView = { let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) tableView.isScrollEnabled = false @@ -114,6 +114,29 @@ final class StatusView: UIView { return tableView }() + let pollStatusStackView = UIStackView() + let pollVoteCountLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Status.Poll.VoteCount.single(0) + return label + }() + let pollStatusDotLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = " · " + return label + }() + let pollCountdownLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .regular)) + label.textColor = Asset.Colors.Label.secondary.color + label.text = L10n.Common.Controls.Status.Poll.timeLeft("6 hours") + return label + }() + // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { let imageView = UIImageView() @@ -239,7 +262,7 @@ extension StatusView { subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) - // status container: [status | image / video | audio | poll] + // status container: [status | image / video | audio | poll | poll status] containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 @@ -275,30 +298,37 @@ extension StatusView { statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) - statusContainerStackView.addArrangedSubview(statusMosaicImageView) - statusPollTableView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(statusPollTableView) - statusPollTableViewHeightLaoutConstraint = statusPollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) + pollTableView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(pollTableView) + statusPollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) NSLayoutConstraint.activate([ statusPollTableViewHeightLaoutConstraint, ]) - statusPollTableViewHeightObservation = statusPollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in + statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in guard let self = self else { return } - guard self.statusPollTableView.contentSize.height != .zero else { + guard self.pollTableView.contentSize.height != .zero else { self.statusPollTableViewHeightLaoutConstraint.constant = 44 return } - self.statusPollTableViewHeightLaoutConstraint.constant = self.statusPollTableView.contentSize.height + self.statusPollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height }) + statusContainerStackView.addArrangedSubview(pollStatusStackView) + pollStatusStackView.axis = .horizontal + pollStatusStackView.addArrangedSubview(pollVoteCountLabel) + pollStatusStackView.addArrangedSubview(pollStatusDotLabel) + pollStatusStackView.addArrangedSubview(pollCountdownLabel) + // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) headerContainerStackView.isHidden = true - statusMosaicImageView.isHidden = true - statusPollTableView.isHidden = true + statusMosaicImageViewContainer.isHidden = true + pollTableView.isHidden = true + pollStatusStackView.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true @@ -390,11 +420,11 @@ struct StatusView_Previews: PreviewProvider { statusView.drawContentWarningImageView() statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageView.setupImageViews(count: 4, maxHeight: 162) + let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) for (i, imageView) in imageViews.enumerated() { imageView.image = images[i] } - statusView.statusMosaicImageView.isHidden = false + statusView.statusMosaicImageViewContainer.isHidden = false return statusView } .previewLayout(.fixed(width: 375, height: 380)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 900094c57..326687b10 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -32,7 +32,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false - statusView.statusPollTableView.dataSource = nil + statusView.pollTableView.dataSource = nil statusView.cleanUpContentWarning() disposeBag.removeAll() observations.removeAll() @@ -85,7 +85,7 @@ extension StatusTableViewCell { bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.delegate = self - statusView.statusMosaicImageView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self } From 384fe6018edb3cdf522b8409b2403cae83542485 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 18:21:54 +0800 Subject: [PATCH 026/400] chore: set the photoButton highlight with some value alpha --- .../MastodonRegisterViewController.swift | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index a5e75e0fa..da6f570db 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -8,9 +8,9 @@ import Combine import MastodonSDK import os.log +import PhotosUI import UIKit import UITextField_Shake -import PhotosUI final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { var disposeBag = Set() @@ -36,7 +36,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O scrollview.showsVerticalScrollIndicator = false scrollview.keyboardDismissMode = .interactive scrollview.alwaysBounceVertical = true - scrollview.clipsToBounds = false // make content could display over bleeding + scrollview.clipsToBounds = false // make content could display over bleeding scrollview.translatesAutoresizingMaskIntoConstraints = false return scrollview }() @@ -216,19 +216,28 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O }() deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } - } extension MastodonRegisterViewController { - override func viewDidLoad() { super.viewDidLoad() setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + NSObject.KeyValueObservingPublisher(object: photoButton, keyPath: \.isHighlighted, options: NSKeyValueObservingOptions.new) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHighlighted in + guard let self = self else { return } + let alpha: CGFloat = isHighlighted ? 0.8 : 1 + self.plusIcon.alpha = alpha + self.plusIconBackground.alpha = alpha + self.photoButton.alpha = alpha + } + .store(in: &disposeBag) + domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() passwordCheckLabel.attributedText = viewModel.attributeStringForPassword() @@ -415,7 +424,7 @@ extension MastodonRegisterViewController { viewModel.isUsernameTaken .receive(on: DispatchQueue.main) - .sink {[weak self] isUsernameTaken in + .sink { [weak self] isUsernameTaken in guard let self = self else { return } if isUsernameTaken { self.usernameIsTakenLabel.isHidden = false @@ -492,10 +501,9 @@ extension MastodonRegisterViewController { .store(in: &disposeBag) if viewModel.approvalRequired { - inviteTextField.delegate = self NSLayoutConstraint.activate([ - inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh) + inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), ]) viewModel.inviteValidateState @@ -503,7 +511,6 @@ extension MastodonRegisterViewController { .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState) - } .store(in: &disposeBag) NotificationCenter.default @@ -518,11 +525,9 @@ extension MastodonRegisterViewController { signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } - } extension MastodonRegisterViewController: UITextFieldDelegate { - func textFieldDidBeginEditing(_ textField: UITextField) { let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -564,7 +569,6 @@ extension MastodonRegisterViewController: UITextFieldDelegate { } extension MastodonRegisterViewController { - @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { view.endEditing(true) } @@ -611,6 +615,5 @@ extension MastodonRegisterViewController { self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) } .store(in: &disposeBag) - } } From a0aa8b9194e5aefc16115028a2fb7368e58ca69f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 3 Mar 2021 11:30:25 +0800 Subject: [PATCH 027/400] chore: use instance method to observe highlighted property --- .../Onboarding/Register/MastodonRegisterViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index da6f570db..d95a03571 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -227,7 +227,8 @@ extension MastodonRegisterViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } - NSObject.KeyValueObservingPublisher(object: photoButton, keyPath: \.isHighlighted, options: NSKeyValueObservingOptions.new) + + photoButton.publisher(for: \.isHighlighted, options: .new) .receive(on: DispatchQueue.main) .sink { [weak self] isHighlighted in guard let self = self else { return } From 492ad98d9bb3f2a483d20699fd4d94959e4dc941 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 3 Mar 2021 12:26:14 +0800 Subject: [PATCH 028/400] chore: change puls.circle.fill --- Mastodon/Generated/Assets.swift | 4 + .../Assets.xcassets/Circles/Contents.json | 9 ++ .../plus.circle.fill.imageset/Contents.json | 21 ++++ .../plus.circle.fill.pdf | 89 +++++++++++++++ .../plus.circle.fill.imageset/Contents.json | 21 ++++ .../plus.circle.fill.pdf | 101 ++++++++++++++++++ .../MastodonRegisterViewController.swift | 29 ++--- 7 files changed, 254 insertions(+), 20 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..d8bdb7063 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -28,6 +28,9 @@ internal enum Asset { internal enum Asset { internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") } + internal enum Circles { + internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill") + } internal enum Colors { internal enum Background { internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") @@ -66,6 +69,7 @@ internal enum Asset { internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") + internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Welcome { diff --git a/Mastodon/Resources/Assets.xcassets/Circles/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json new file mode 100644 index 000000000..580a3f7a0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "plus.circle.fill.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf new file mode 100644 index 000000000..efe6b69dc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/plus.circle.fill.pdf @@ -0,0 +1,89 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +12.007203 -0.000002 m +18.586674 -0.000002 24.000000 5.413311 24.000000 12.007201 c +24.000000 18.586702 18.586674 24.000000 11.992814 24.000000 c +5.413314 24.000000 0.000000 18.586702 0.000000 12.007201 c +0.000000 5.413311 5.413314 -0.000002 12.007203 -0.000002 c +h +6.478707 12.007201 m +6.478707 12.827837 7.068974 13.432522 7.875220 13.432522 c +10.567494 13.432522 l +10.567494 16.124798 l +10.567494 16.931015 11.172179 17.535698 11.992814 17.535698 c +12.813449 17.535698 13.418134 16.931015 13.418134 16.124798 c +13.418134 13.432522 l +16.110380 13.432522 l +16.931015 13.432522 17.521311 12.827837 17.521311 12.007201 c +17.521311 11.186566 16.931015 10.581882 16.110380 10.581882 c +13.418134 10.581882 l +13.418134 7.889637 l +13.418134 7.083389 12.813449 6.478704 11.992814 6.478704 c +11.172179 6.478704 10.567494 7.083389 10.567494 7.889637 c +10.567494 10.581882 l +7.875220 10.581882 l +7.068974 10.581882 6.478707 11.186566 6.478707 12.007201 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1071 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001161 00000 n +0000001184 00000 n +0000001357 00000 n +0000001431 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1490 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json new file mode 100644 index 000000000..580a3f7a0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "plus.circle.fill.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf new file mode 100644 index 000000000..f4a613417 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf @@ -0,0 +1,101 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +1.000000 1.000000 1.000000 scn +30.000000 15.000000 m +30.000000 6.715729 23.284271 0.000000 15.000000 0.000000 c +6.715729 0.000000 0.000000 6.715729 0.000000 15.000000 c +0.000000 23.284271 6.715729 30.000000 15.000000 30.000000 c +23.284271 30.000000 30.000000 23.284271 30.000000 15.000000 c +h +f +n +Q +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +15.009004 0.000000 m +23.233341 0.000000 30.000000 6.766640 30.000000 15.009003 c +30.000000 23.233379 23.233341 30.000000 14.991017 30.000000 c +6.766642 30.000000 0.000000 23.233379 0.000000 15.009003 c +0.000000 6.766640 6.766643 0.000000 15.009004 0.000000 c +h +8.098384 15.009003 m +8.098384 16.034798 8.836217 16.790653 9.844025 16.790653 c +13.209368 16.790653 l +13.209368 20.155996 l +13.209368 21.163769 13.965223 21.919624 14.991017 21.919624 c +16.016811 21.919624 16.772667 21.163769 16.772667 20.155996 c +16.772667 16.790653 l +20.137974 16.790653 l +21.163769 16.790653 21.901638 16.034798 21.901638 15.009003 c +21.901638 13.983208 21.163769 13.227352 20.137974 13.227352 c +16.772667 13.227352 l +16.772667 9.862047 l +16.772667 8.854239 16.016811 8.098381 14.991017 8.098381 c +13.965223 8.098381 13.209368 8.854239 13.209368 9.862047 c +13.209368 13.227352 l +9.844025 13.227352 l +8.836217 13.227352 8.098384 13.983208 8.098384 15.009003 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1426 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001516 00000 n +0000001539 00000 n +0000001712 00000 n +0000001786 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1845 +%%EOF \ No newline at end of file diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index d95a03571..ba515e2da 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -71,23 +71,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return button }() - let plusIconBackground: UIImageView = { - let icon = UIImageView() - let boldFont = UIFont.systemFont(ofSize: 24) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "plus.circle", withConfiguration: configuration) - icon.image = image - icon.tintColor = .white - return icon - }() - let plusIcon: UIImageView = { let icon = UIImageView() - let boldFont = UIFont.systemFont(ofSize: 24) - let configuration = UIImage.SymbolConfiguration(font: boldFont) - let image = UIImage(systemName: "plus.circle.fill", withConfiguration: configuration) + + let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) icon.image = image icon.tintColor = Asset.Colors.Icon.plus.color + icon.backgroundColor = .white return icon }() @@ -234,7 +224,6 @@ extension MastodonRegisterViewController { guard let self = self else { return } let alpha: CGFloat = isHighlighted ? 0.8 : 1 self.plusIcon.alpha = alpha - self.plusIconBackground.alpha = alpha self.photoButton.alpha = alpha } .store(in: &disposeBag) @@ -305,12 +294,7 @@ extension MastodonRegisterViewController { photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor), photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor), ]) - plusIconBackground.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(plusIconBackground) - NSLayoutConstraint.activate([ - plusIconBackground.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), - plusIconBackground.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), - ]) + plusIcon.translatesAutoresizingMaskIntoConstraints = false photoView.addSubview(plusIcon) NSLayoutConstraint.activate([ @@ -526,6 +510,11 @@ extension MastodonRegisterViewController { signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + plusIcon.layer.cornerRadius = plusIcon.frame.width/2 + plusIcon.clipsToBounds = true + } } extension MastodonRegisterViewController: UITextFieldDelegate { From 1e691a2a762f1b2921dc7a92f4576eda9ad0b121 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 12:46:38 +0800 Subject: [PATCH 029/400] fix: AutoLayout warning for poll UI --- Mastodon/Diffiable/Section/PollSection.swift | 2 ++ Mastodon/Scene/Share/View/Content/StatusView.swift | 4 ++++ .../Share/View/TableviewCell/PollOptionTableViewCell.swift | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index b48231dbf..08f1f8710 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,5 +39,7 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title + cell.configureCheckmark(state: itemAttribute.voted ? .on : .off) + } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 702d9c7e4..55f785ca3 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -276,6 +276,7 @@ extension StatusView { activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), ]) + activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false statusTextContainerView.addSubview(contentWarningBlurContentImageView) NSLayoutConstraint.activate([ @@ -320,6 +321,9 @@ extension StatusView { pollStatusStackView.addArrangedSubview(pollVoteCountLabel) pollStatusStackView.addArrangedSubview(pollStatusDotLabel) pollStatusStackView.addArrangedSubview(pollCountdownLabel) + pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 5372380bd..eb427bc31 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -128,7 +128,7 @@ extension PollOptionTableViewCell { roundedBackgroundView.layer.cornerCurve = .circular checkmarkBackgroundView.layer.masksToBounds = true - checkmarkBackgroundView.layer.cornerRadius = checkmarkBackgroundView.bounds.height * 0.5 + checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5 checkmarkBackgroundView.layer.cornerCurve = .circular } From 30c035e09a25d68539f0cb63510212e4613a2803 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 16:12:48 +0800 Subject: [PATCH 030/400] feat: implement auto refresh logic for Poll --- CoreDataStack/Entity/Poll.swift | 28 +++++++ CoreDataStack/Entity/PollOption.swift | 16 ++++ Localization/app.json | 5 ++ Mastodon.xcodeproj/project.pbxproj | 36 +++++++-- Mastodon/Diffiable/Item/PollItem.swift | 12 +-- Mastodon/Diffiable/Section/PollSection.swift | 3 +- .../Diffiable/Section/StatusSection.swift | 35 ++++++-- Mastodon/Generated/Strings.swift | 12 +++ ...Provider+StatusTableViewCellDelegate.swift | 71 ++++++++++++++++ ...er+TimelinePostTableViewCellDelegate.swift | 81 ------------------- .../StatusProvider+UITableViewDelegate.swift | 71 ++++++++++++++++ .../StatusProvider/StatusProvider.swift | 6 +- ...ableViewCellHeightCacheableContainer.swift | 12 +++ .../Resources/en.lproj/Localizable.strings | 3 + ...imelineViewController+StatusProvider.swift | 30 +++---- .../HomeTimelineViewController.swift | 27 ++++--- .../HomeTimelineViewModel+Diffable.swift | 4 +- ...imelineViewController+StatusProvider.swift | 31 +++---- .../PublicTimelineViewController.swift | 2 +- .../PublicTimelineViewModel+Diffable.swift | 4 +- .../Scene/Share/View/Content/StatusView.swift | 27 +++++-- .../Share/View/TableView/PollTableView.swift | 10 +++ .../PollOptionTableViewCell.swift | 30 +++++-- .../TableviewCell/StatusTableViewCell.swift | 24 +++++- .../APIService/APIService+Favorite.swift | 4 +- .../Service/APIService/APIService+Poll.swift | 71 ++++++++++++++++ .../CoreData/APIService+CoreData+Toot.swift | 61 ++++++++++++-- .../API/Mastodon+API+Favorites.swift | 45 ++++++++++- .../MastodonSDK/API/Mastodon+API+Polls.swift | 51 ++++++++++++ .../API/Mastodon+API+Timeline.swift | 3 +- .../MastodonSDK/API/Mastodon+API.swift | 3 + 31 files changed, 645 insertions(+), 173 deletions(-) create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift delete mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift create mode 100644 Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift create mode 100644 Mastodon/Scene/Share/View/TableView/PollTableView.swift create mode 100644 Mastodon/Service/APIService/APIService+Poll.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index 1e8b2528f..a7f6d431a 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -56,6 +56,34 @@ extension Poll { return poll } + public func update(expiresAt: Date?) { + if self.expiresAt != expiresAt { + self.expiresAt = expiresAt + } + } + + public func update(expired: Bool) { + if self.expired != expired { + self.expired = expired + } + } + + public func update(votesCount: Int) { + if self.votesCount.intValue != votesCount { + self.votesCount = NSNumber(value: votesCount) + } + } + + public func update(votersCount: Int?) { + if self.votersCount?.intValue != votersCount { + self.votersCount = votersCount.flatMap { NSNumber(value: $0) } + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } extension Poll { diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index f0d3219d8..6c88fe609 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -50,6 +50,22 @@ extension PollOption { return option } + public func update(votesCount: Int?) { + if self.votesCount?.intValue != votesCount { + self.votesCount = votesCount.flatMap { NSNumber(value: $0) } + } + } + + public func update(votedBy: MastodonUser) { + if !(self.votedBy ?? Set()).contains(votedBy) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + } + } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } extension PollOption { diff --git a/Localization/app.json b/Localization/app.json index 7b118b34d..d35443b74 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -60,10 +60,15 @@ "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", "poll": { + "vote": "Vote", "vote_count": { "single": "%d vote", "multiple": "%d votes", }, + "voter_count": { + "single": "%d voter", + "multiple": "%d voters", + }, "time_left": "%s left" } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7509264bb..ed78ec478 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -77,7 +77,7 @@ 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */; }; + 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; @@ -94,6 +94,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; @@ -125,6 +126,9 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; + DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; + DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -292,7 +296,7 @@ 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+TimelinePostTableViewCellDelegate.swift"; sourceTree = ""; }; + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; }; 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; }; 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; @@ -313,6 +317,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -349,6 +354,9 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; + DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; + DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -551,7 +559,8 @@ children = ( 2D38F1FD25CD481700561493 /* StatusProvider.swift */, 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, - 2DF75BA025D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift */, + 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, + DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, ); path = StatusProvider; sourceTree = ""; @@ -617,9 +626,10 @@ 2D38F1FC25CD47D900561493 /* StatusProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, + 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, + DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, - 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, ); path = Protocol; sourceTree = ""; @@ -672,6 +682,7 @@ 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, 2D152A8A25C295B8009AA50C /* Content */, + DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); path = View; @@ -752,6 +763,14 @@ path = CoreDataStack; sourceTree = ""; }; + DB1D187125EF5BBD003F1F23 /* TableView */ = { + isa = PBXGroup; + children = ( + DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */, + ); + path = TableView; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -850,15 +869,16 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */, 2D61335625C1887F00CAE157 /* Persist */, 2D61335D25C1894B00CAE157 /* APIService.swift */, + DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, - DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, + DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, ); path = APIService; sourceTree = ""; @@ -1480,6 +1500,7 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, + DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, @@ -1499,6 +1520,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -1515,9 +1537,11 @@ DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, - 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, + 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 1ae8f34e3..ca1bbc364 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -15,18 +15,20 @@ enum PollItem { extension PollItem { class Attribute: Hashable { - var voted: Bool = false + // var pollVotable: Bool + var isOptionVoted: Bool - init(voted: Bool = false) { - self.voted = voted + init(isOptionVoted: Bool) { + // self.pollVotable = pollVotable + self.isOptionVoted = isOptionVoted } static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { - return lhs.voted == rhs.voted + return lhs.isOptionVoted == rhs.isOptionVoted } func hash(into hasher: inout Hasher) { - hasher.combine(voted) + hasher.combine(isOptionVoted) } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 08f1f8710..de303d4a0 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,7 +39,6 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title - cell.configureCheckmark(state: itemAttribute.voted ? .on : .off) - + cell.configure(state: itemAttribute.isOptionVoted ? .on : .off) } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 2fd87f4d1..2c88f7f76 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -21,11 +21,11 @@ extension StatusSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak timelinePostTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in - guard let timelinePostTableViewCellDelegate = timelinePostTableViewCellDelegate else { return UITableViewCell() } + UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } switch item { case .homeTimelineIndex(objectID: let objectID, let attribute): @@ -36,7 +36,7 @@ extension StatusSection { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute) } - cell.delegate = timelinePostTableViewCellDelegate + cell.delegate = statusTableViewCellDelegate return cell case .toot(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell @@ -47,7 +47,7 @@ extension StatusSection { let toot = managedObjectContext.object(with: objectID) as! Toot StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute) } - cell.delegate = timelinePostTableViewCellDelegate + cell.delegate = statusTableViewCellDelegate return cell case .publicMiddleLoader(let upperTimelineTootID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell @@ -158,9 +158,27 @@ extension StatusSection { if let poll = (toot.reblog ?? toot).poll { cell.statusView.pollTableView.isHidden = false cell.statusView.pollStatusStackView.isHidden = false + cell.statusView.pollVoteButton.isHidden = !poll.multiple + cell.statusView.pollVoteCountLabel.text = { + if poll.multiple { + let count = poll.votersCount?.intValue ?? 0 + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoterCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) + } + } else { + let count = poll.votesCount.intValue + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoteCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) + } + } + }() let managedObjectContext = toot.managedObjectContext! - cell.statusView.statusPollTableViewDataSource = PollSection.tableViewDiffableDataSource( + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( for: cell.statusView.pollTableView, managedObjectContext: managedObjectContext ) @@ -171,15 +189,16 @@ extension StatusSection { .sorted(by: { $0.index.intValue < $1.index.intValue }) .map { option -> PollItem in let isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - let attribute = PollItem.Attribute(voted: isVoted) + let attribute = PollItem.Attribute(isOptionVoted: isVoted) let option = PollItem.opion(objectID: option.objectID, attribute: attribute) return option } snapshot.appendItems(pollItems, toSection: .main) - cell.statusView.statusPollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } else { cell.statusView.pollTableView.isHidden = true cell.statusView.pollStatusStackView.isHidden = true + cell.statusView.pollVoteButton.isHidden = true } // toolbar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c049f4fca..190657e94 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -73,6 +73,8 @@ internal enum L10n { internal static func timeLeft(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) } + /// Vote + internal static let vote = L10n.tr("Localizable", "Common.Controls.Status.Poll.Vote") internal enum VoteCount { /// %d votes internal static func multiple(_ p1: Int) -> String { @@ -83,6 +85,16 @@ internal enum L10n { return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoteCount.Single", p1) } } + internal enum VoterCount { + /// %d voters + internal static func multiple(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Multiple", p1) + } + /// %d voter + internal static func single(_ p1: Int) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.Poll.VoterCount.Single", p1) + } + } } } internal enum Timeline { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift new file mode 100644 index 000000000..6ef4b5f94 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -0,0 +1,71 @@ +// +// StatusProvider+StatusTableViewCellDelegate.swift +// Mastodon +// +// Created by sxiaojian on 2021/2/8. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import ActiveLabel + +// MARK: - ActionToolbarContainerDelegate +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusTextSensitive = false + case .toot(_, let attribute): + attribute.isStatusTextSensitive = false + default: + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + diffableDataSource.apply(snapshot) + } + +} + +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusSensitive = false + case .toot(_, let attribute): + attribute.isStatusSensitive = false + default: + return + } + + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + UIView.animate(withDuration: 0.33) { + cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil + cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 + } completion: { _ in + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift deleted file mode 100644 index 4679969e2..000000000 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+TimelinePostTableViewCellDelegate.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// StatusProvider+TimelinePostTableViewCellDelegate.swift -// Mastodon -// -// Created by sxiaojian on 2021/2/8. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK -import ActiveLabel - -// MARK: - ActionToolbarContainerDelegate -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) - } - - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusTextSensitive = false - case .toot(_, let attribute): - attribute.isStatusTextSensitive = false - default: - return - } - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - diffableDataSource.apply(snapshot) - } - .store(in: &cell.disposeBag) - } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - - } - - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - item(for: cell, indexPath: nil) - .receive(on: DispatchQueue.main) - .sink { [weak self] item in - guard let _ = self else { return } - guard let item = item else { return } - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .toot(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - .store(in: &cell.disposeBag) - } - -} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift new file mode 100644 index 000000000..ea222c763 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -0,0 +1,71 @@ +// +// StatusProvider+UITableViewDelegate.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +extension StatusTableViewCellDelegate where Self: StatusProvider { + // TODO: + // func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // } + + func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let now = Date() + var pollID: Mastodon.Entity.Poll.ID? + toot(for: cell, indexPath: indexPath) + .compactMap { [weak self] toot -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + guard let toot = (toot?.reblog ?? toot) else { return nil } + guard let poll = toot.poll else { return nil } + pollID = poll.id + + // not expired AND last update > 60s + guard !poll.expired else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + return nil + } + let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) + guard timeIntervalSinceUpdate > 60 else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate) + return nil + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + + return self.context.apiService.poll( + domain: toot.domain, + pollID: poll.id, + pollObjectID: poll.objectID, + mastodonAuthenticationBox: authenticationBox + ) + } + .setFailureType(to: Error.self) + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", ((#file as NSString).lastPathComponent), #line, #function, pollID ?? "?", error.localizedDescription) + case .finished: + break + } + }, receiveValue: { response in + let poll = response.value + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + }) + .store(in: &disposeBag) + } + +} + +extension StatusTableViewCellDelegate where Self: StatusProvider { + + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 781ccc9f3..a0a7116fc 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -7,13 +7,17 @@ import UIKit import Combine +import CoreData import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { + // async func toot() -> Future func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future func toot(for cell: UICollectionViewCell) -> Future + // sync + var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } - func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? } diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift new file mode 100644 index 000000000..1b0350086 --- /dev/null +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -0,0 +1,12 @@ +// +// TableViewCellHeightCacheableContainer.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +protocol TableViewCellHeightCacheableContainer: UIViewController { + // TODO: +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 9b1dfdf7a..a9480500f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -18,8 +18,11 @@ "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.Vote" = "Vote"; "Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; "Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; +"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; +"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index 697820072..a0d9204ba 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreData import CoreDataStack // MARK: - StatusProvider @@ -47,25 +48,26 @@ extension HomeTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } + var managedObjectContext: NSManagedObjectContext { + return viewModel.fetchedResultsController.managedObjectContext + } + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { return viewModel.diffableDataSource } - func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { - return Future { promise in - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - promise(.success(nil)) - return - } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - promise(.success(item)) + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index d3906fd90..b9d0f94e1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -106,7 +106,7 @@ extension HomeTimelineViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self, + statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) @@ -220,16 +220,21 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - UITableViewDelegate extension HomeTimelineViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - - guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - return 200 - } - // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - - return ceil(frame.height) + // TODO: + // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + // + // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + // return 200 + // } + // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + // + // return ceil(frame.height) + // } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index d5345de4f..fffa4b7f7 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -15,7 +15,7 @@ extension HomeTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -28,7 +28,7 @@ extension HomeTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index 6d83e79af..aceb83718 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -8,12 +8,13 @@ import os.log import UIKit import Combine +import CoreData import CoreDataStack import MastodonSDK // MARK: - StatusProvider extension PublicTimelineViewController: StatusProvider { - + func toot() -> Future { return Future { promise in promise(.success(nil)) } } @@ -48,25 +49,25 @@ extension PublicTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } + var managedObjectContext: NSManagedObjectContext { + return viewModel.fetchedResultsController.managedObjectContext + } + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { return viewModel.diffableDataSource } - func item(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { - return Future { promise in - guard let diffableDataSource = self.viewModel.diffableDataSource else { - assertionFailure() - promise(.success(nil)) - return - } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), - let item = diffableDataSource.itemIdentifier(for: indexPath) else { - promise(.success(nil)) - return - } - - promise(.success(item)) + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil } + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index dd5ffc84e..98d2dbd94 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -76,7 +76,7 @@ extension PublicTimelineViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - timelinePostTableViewCellDelegate: self, + statusTableViewCellDelegate: self, timelineMiddleLoaderTableViewCellDelegate: self ) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index f9c92fa0f..fa17319b4 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -14,7 +14,7 @@ extension PublicTimelineViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - timelinePostTableViewCellDelegate: StatusTableViewCellDelegate, + statusTableViewCellDelegate: StatusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) @@ -27,7 +27,7 @@ extension PublicTimelineViewModel { dependency: dependency, managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, - timelinePostTableViewCellDelegate: timelinePostTableViewCellDelegate, + statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) items.value = [] diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 55f785ca3..f6095db07 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -25,8 +25,8 @@ final class StatusView: UIView { weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false - var statusPollTableViewDataSource: UITableViewDiffableDataSource? - var statusPollTableViewHeightLaoutConstraint: NSLayoutConstraint! + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! let headerContainerStackView = UIStackView() @@ -105,8 +105,8 @@ final class StatusView: UIView { }() let statusMosaicImageViewContainer = MosaicImageViewContainer() - let pollTableView: UITableView = { - let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let pollTableView: PollTableView = { + let tableView = PollTableView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) tableView.register(PollOptionTableViewCell.self, forCellReuseIdentifier: String(describing: PollOptionTableViewCell.self)) tableView.isScrollEnabled = false tableView.separatorStyle = .none @@ -136,6 +136,15 @@ final class StatusView: UIView { label.text = L10n.Common.Controls.Status.Poll.timeLeft("6 hours") return label }() + let pollVoteButton: UIButton = { + let button = HitTestExpandedButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .regular)) + button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) + button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted) + button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) + return button + }() // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { @@ -302,18 +311,18 @@ extension StatusView { statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) pollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(pollTableView) - statusPollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) + pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) NSLayoutConstraint.activate([ - statusPollTableViewHeightLaoutConstraint, + pollTableViewHeightLaoutConstraint, ]) statusPollTableViewHeightObservation = pollTableView.observe(\.contentSize, options: .new, changeHandler: { [weak self] tableView, _ in guard let self = self else { return } guard self.pollTableView.contentSize.height != .zero else { - self.statusPollTableViewHeightLaoutConstraint.constant = 44 + self.pollTableViewHeightLaoutConstraint.constant = 44 return } - self.statusPollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height + self.pollTableViewHeightLaoutConstraint.constant = self.pollTableView.contentSize.height }) statusContainerStackView.addArrangedSubview(pollStatusStackView) @@ -321,9 +330,11 @@ extension StatusView { pollStatusStackView.addArrangedSubview(pollVoteCountLabel) pollStatusStackView.addArrangedSubview(pollStatusDotLabel) pollStatusStackView.addArrangedSubview(pollCountdownLabel) + pollStatusStackView.addArrangedSubview(pollVoteButton) pollVoteCountLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) pollStatusDotLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) diff --git a/Mastodon/Scene/Share/View/TableView/PollTableView.swift b/Mastodon/Scene/Share/View/TableView/PollTableView.swift new file mode 100644 index 000000000..d90be2b09 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableView/PollTableView.swift @@ -0,0 +1,10 @@ +// +// PollTableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit + +final class PollTableView: UITableView { } diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index eb427bc31..1da1246b1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine final class PollOptionTableViewCell: UITableViewCell { @@ -14,6 +15,9 @@ final class PollOptionTableViewCell: UITableViewCell { static let verticalMargin: CGFloat = 5 static let checkmarkImageSize = CGSize(width: 26, height: 26) + private var viewStateDisposeBag = Set() + private(set) var pollState: PollState = .off + let roundedBackgroundView = UIView() let checkmarkBackgroundView: UIView = { @@ -57,6 +61,22 @@ final class PollOptionTableViewCell: UITableViewCell { super.init(coder: coder) _init() } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + switch pollState { + case .off, .none: + let color = Asset.Colors.Background.systemGroupedBackground.color + self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + case .on: + break + } + } } @@ -113,7 +133,7 @@ extension PollOptionTableViewCell { optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - configureCheckmark(state: .none) + configure(state: .none) } override func layoutSubviews() { @@ -136,13 +156,13 @@ extension PollOptionTableViewCell { extension PollOptionTableViewCell { - enum CheckmarkState { + enum PollState { case none case off case on } - func configureCheckmark(state: CheckmarkState) { + func configure(state: PollState) { switch state { case .none: checkmarkBackgroundView.backgroundColor = .clear @@ -185,13 +205,13 @@ struct PollTableViewCell_Previews: PreviewProvider { .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configureCheckmark(state: .off) + cell.configure(state: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configureCheckmark(state: .on) + cell.configure(state: .on) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 326687b10..1c45bfe2d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -9,13 +9,15 @@ import os.log import UIKit import AVKit import Combine +import CoreData +import CoreDataStack protocol StatusTableViewCellDelegate: class { + var managedObjectContext: NSManagedObjectContext { get } func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - } final class StatusTableViewCell: UITableViewCell { @@ -32,8 +34,8 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() statusView.isStatusTextSensitive = false - statusView.pollTableView.dataSource = nil statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil disposeBag.removeAll() observations.removeAll() } @@ -85,12 +87,30 @@ extension StatusTableViewCell { bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color statusView.delegate = self + statusView.pollTableView.delegate = self statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self } } +// MARK: - UITableViewDelegate +extension StatusTableViewCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + guard let item = diffableDataSource.itemIdentifier(for: indexPath), + case let .opion(objectID, _) = item, + let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { + return false + } + + return !option.poll.expired + } else { + return true + } + } +} + // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 34bd3f0e4..e1d5febe7 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -94,7 +94,7 @@ extension APIService { assertionFailure() return } - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot, in: mastodonAuthenticationBox.domain, entity: entity, networkDate: response.networkDate) + APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) @@ -132,7 +132,7 @@ extension APIService { let requestMastodonUserID = mastodonAuthenticationBox.userID let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.getFavoriteStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) .map { response -> AnyPublisher, Error> in let log = OSLog.api diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift new file mode 100644 index 000000000..33944c227 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -0,0 +1,71 @@ +// +// APIService+Poll.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func poll( + domain: String, + pollID: Mastodon.Entity.Poll.ID, + pollObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Polls.poll( + session: session, + domain: domain, + pollID: pollID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + guard let requestMastodonUser = _requestMastodonUser else { + assertionFailure() + return + } + guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } + APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index eeb2afa2a..6868c668f 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -44,7 +44,7 @@ extension APIService.CoreData { if let oldToot = oldToot { // merge old Toot - APIService.CoreData.mergeToot(for: requestMastodonUser, old: oldToot,in: domain, entity: entity, networkDate: networkDate) + APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) return (oldToot, false, false) } else { let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log) @@ -106,10 +106,34 @@ extension APIService.CoreData { } } - static func mergeToot(for requestMastodonUser: MastodonUser?, old toot: Toot,in domain: String, entity: Mastodon.Entity.Status, networkDate: Date) { + static func merge( + toot: Toot, + entity: Mastodon.Entity.Status, + requestMastodonUser: MastodonUser?, + domain: String, + networkDate: Date + ) { guard networkDate > toot.updatedAt else { return } - // merge + // merge poll + if let poll = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count { + oldPoll.update(expiresAt: poll.expiresAt) + oldPoll.update(expired: poll.expired) + oldPoll.update(votesCount: poll.votesCount) + oldPoll.update(votersCount: poll.votersCount) + + let oldOptions = oldPoll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + for (i, (option, oldOption)) in zip(poll.options, oldOptions).enumerated() { + let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + oldOption.update(votesCount: option.votesCount) + votedBy.flatMap { oldOption.update(votedBy: $0) } + oldOption.didUpdate(at: networkDate) + } + + oldPoll.didUpdate(at: networkDate) + } + + // merge metrics if entity.favouritesCount != toot.favouritesCount.intValue { toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) } @@ -122,6 +146,7 @@ extension APIService.CoreData { toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) } + // merge relationship if let mastodonUser = requestMastodonUser { if let favourited = entity.favourited { toot.update(liked: favourited, mastodonUser: mastodonUser) @@ -142,10 +167,36 @@ extension APIService.CoreData { // merge user mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) - // merge indirect reblog & quote + + // merge indirect reblog if let reblog = toot.reblog, let reblogEntity = entity.reblog { - mergeToot(for: requestMastodonUser, old: reblog,in: domain, entity: reblogEntity, networkDate: networkDate) + merge(toot: reblog, entity: reblogEntity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } } } + +extension APIService.CoreData { + static func merge( + poll: Poll, + entity: Mastodon.Entity.Poll, + requestMastodonUser: MastodonUser?, + domain: String, + networkDate: Date + ) { + poll.update(expiresAt: entity.expiresAt) + poll.update(expired: entity.expired) + poll.update(votesCount: entity.votesCount) + poll.update(votersCount: entity.votersCount) + + let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { + let votedBy: MastodonUser? = (entity.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + option.update(votesCount: optionEntity.votesCount) + votedBy.flatMap { option.update(votedBy: $0) } + option.didUpdate(at: networkDate) + } + + poll.didUpdate(at: networkDate) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 6942fa2f1..54a6c7f82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -30,6 +30,20 @@ extension Mastodon.API.Favorites { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } + /// Favourite / Undo Favourite + /// + /// Add a status to your favourites list / Remove a status from your favourites list + /// + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) @@ -42,7 +56,21 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } - public static func getFavoriteByUserLists(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { + /// Favourited by + /// + /// View who favourited a given status. + /// + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -53,7 +81,20 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } - public static func getFavoriteStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + /// Favourited statuses + /// + /// Using this endpoint to view the favourited list for user + /// + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/favourites/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { let url = favoritesStatusesEndpointURL(domain: domain) let request = Mastodon.API.get(url: url, query: query, authorization: authorization) return session.dataTaskPublisher(for: request) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift new file mode 100644 index 000000000..6329a4403 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -0,0 +1,51 @@ +// +// Mastodon+API+Polls.swift +// +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import Foundation +import Combine + +extension Mastodon.API.Polls { + + static func viewPollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View a poll + /// + /// Using this endpoint to view the poll of status + /// + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func poll( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewPollEndpointURL(domain: domain, pollID: pollID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index d4ec364bf..03a718b5b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -53,13 +53,14 @@ extension Mastodon.API.Timeline { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/2/19 + /// 2021/3/3 /// # Reference /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `PublicTimelineQuery` with query parameters + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func home( session: URLSession, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 92897090c..5a55ee103 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/1/25. // +import os.log import Foundation import enum NIOHTTP1.HTTPResponseStatus @@ -93,6 +94,7 @@ extension Mastodon.API { public enum Instance { } public enum OAuth { } public enum Onboarding { } + public enum Polls { } public enum Timeline { } public enum Favorites { } } @@ -155,6 +157,7 @@ extension Mastodon.API { return try Mastodon.API.decoder.decode(type, from: data) } catch let decodeError { #if DEBUG + os_log(.info, "%{public}s[%{public}ld], %{public}s: decode fail. content %s", ((#file as NSString).lastPathComponent), #line, #function, String(data: data, encoding: .utf8) ?? "") debugPrint(decodeError) #endif From 028f3a9404d04e1f23f45c7ff5ff13c40ebb14d7 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 3 Mar 2021 19:34:29 +0800 Subject: [PATCH 031/400] feat: make poll cell label appearance update according to the underneath background --- .../CoreData.xcdatamodel/contents | 6 +- CoreDataStack/Entity/MastodonUser.swift | 1 + CoreDataStack/Entity/Poll.swift | 21 +++ CoreDataStack/Entity/PollOption.swift | 12 +- Localization/app.json | 3 +- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Diffiable/Item/PollItem.swift | 28 +++- Mastodon/Diffiable/Section/PollSection.swift | 45 ++++- .../Diffiable/Section/StatusSection.swift | 157 +++++++++++++----- Mastodon/Generated/Assets.swift | 4 + Mastodon/Generated/Strings.swift | 2 + .../Colors/Background/Poll/Contents.json | 9 + .../Poll/disabled.colorset/Contents.json | 20 +++ .../Poll/highlight.colorset/Contents.json | 20 +++ .../Resources/en.lproj/Localizable.strings | 1 + .../View/Content/VoteProgressStripView.swift | 137 +++++++++++++++ .../PollOptionTableViewCell.swift | 114 ++++++++----- .../TableviewCell/StatusTableViewCell.swift | 1 + .../CoreData/APIService+CoreData+Toot.swift | 27 +-- 19 files changed, 494 insertions(+), 118 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 96ec6971d..3f8fe73f9 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -84,6 +84,7 @@ + @@ -105,6 +106,7 @@ + @@ -166,9 +168,9 @@ - + - + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 8ecf66282..dc88d48a2 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -38,6 +38,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var muted: Set? @NSManaged public private(set) var bookmarked: Set? @NSManaged public private(set) var votePollOptions: Set? + @NSManaged public private(set) var votePolls: Set? } diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index a7f6d431a..cc5e7bbcb 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -26,6 +26,9 @@ public final class Poll: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var options: Set + + // many-to-many relationship + @NSManaged public private(set) var votedBy: Set? } extension Poll { @@ -39,6 +42,7 @@ extension Poll { public static func insert( into context: NSManagedObjectContext, property: Property, + votedBy: MastodonUser?, options: [PollOption] ) -> Poll { let poll: Poll = context.insertObject() @@ -50,7 +54,12 @@ extension Poll { poll.votesCount = property.votesCount poll.votersCount = property.votersCount + poll.updatedAt = property.networkDate + + if let votedBy = votedBy { + poll.mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(votedBy) + } poll.mutableSetValue(forKey: #keyPath(Poll.options)).addObjects(from: options) return poll @@ -80,6 +89,18 @@ extension Poll { } } + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).add(by) + } + } else { + if (votedBy ?? Set()).contains(by) { + mutableSetValue(forKey: #keyPath(Poll.votedBy)).remove(by) + } + } + } + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index 6c88fe609..14a076144 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -56,9 +56,15 @@ extension PollOption { } } - public func update(votedBy: MastodonUser) { - if !(self.votedBy ?? Set()).contains(votedBy) { - self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(votedBy) + public func update(voted: Bool, by: MastodonUser) { + if voted { + if !(self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) + } + } else { + if !(self.votedBy ?? Set()).contains(by) { + self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) + } } } diff --git a/Localization/app.json b/Localization/app.json index d35443b74..d1b0e3c5c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,7 +69,8 @@ "single": "%d voter", "multiple": "%d voters", }, - "time_left": "%s left" + "time_left": "%s left", + "closed": "Closed" } }, "timeline": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ed78ec478..439696738 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -129,6 +129,7 @@ DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; + DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -357,6 +358,7 @@ DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; + DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -525,6 +527,7 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, + DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */, ); path = Content; sourceTree = ""; @@ -1520,6 +1523,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index ca1bbc364..e4b0ff8df 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -15,20 +15,34 @@ enum PollItem { extension PollItem { class Attribute: Hashable { - // var pollVotable: Bool - var isOptionVoted: Bool - init(isOptionVoted: Bool) { - // self.pollVotable = pollVotable - self.isOptionVoted = isOptionVoted + enum SelectState: Equatable, Hashable { + case none + case off + case on + } + + enum VoteState: Equatable, Hashable { + case hidden + case reveal(voted: Bool, percentage: Double) + } + + var selectState: SelectState + var voteState: VoteState + + init(selectState: SelectState, voteState: VoteState) { + self.selectState = selectState + self.voteState = voteState } static func == (lhs: PollItem.Attribute, rhs: PollItem.Attribute) -> Bool { - return lhs.isOptionVoted == rhs.isOptionVoted + return lhs.selectState == rhs.selectState && + lhs.voteState == rhs.voteState } func hash(into hasher: inout Hasher) { - hasher.combine(isOptionVoted) + hasher.combine(selectState) + hasher.combine(voteState) } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index de303d4a0..eff868a79 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -39,6 +39,49 @@ extension PollSection { itemAttribute: PollItem.Attribute ) { cell.optionLabel.text = pollOption.title - cell.configure(state: itemAttribute.isOptionVoted ? .on : .off) + configure(cell: cell, selectState: itemAttribute.selectState) + configure(cell: cell, voteState: itemAttribute.voteState) } } + +extension PollSection { + + static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) { + switch state { + case .none: + cell.checkmarkBackgroundView.isHidden = true + cell.checkmarkImageView.isHidden = true + case .off: + cell.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + cell.checkmarkBackgroundView.layer.borderWidth = 1 + cell.checkmarkBackgroundView.isHidden = false + cell.checkmarkImageView.isHidden = true + case .on: + cell.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + cell.checkmarkBackgroundView.layer.borderWidth = 0 + cell.checkmarkBackgroundView.isHidden = false + cell.checkmarkImageView.isHidden = false + } + + cell.selectState = state + } + + static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { + switch state { + case .hidden: + cell.optionPercentageLabel.isHidden = true + case .reveal(let voted, let percentage): + cell.optionPercentageLabel.isHidden = false + cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.voteProgressStripView.progress.send(CGFloat(percentage)) + } + cell.voteState = state + + cell.layoutIfNeeded() + cell.updateTextAppearance() + } + +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 2c88f7f76..38e6a2b68 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -66,6 +66,9 @@ extension StatusSection { } } } +} + +extension StatusSection { static func configure( cell: StatusTableViewCell, @@ -155,52 +158,20 @@ extension StatusSection { cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll - if let poll = (toot.reblog ?? toot).poll { - cell.statusView.pollTableView.isHidden = false - cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteButton.isHidden = !poll.multiple - cell.statusView.pollVoteCountLabel.text = { - if poll.multiple { - let count = poll.votersCount?.intValue ?? 0 - if count > 1 { - return L10n.Common.Controls.Status.Poll.VoterCount.single(count) - } else { - return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) - } - } else { - let count = poll.votesCount.intValue - if count > 1 { - return L10n.Common.Controls.Status.Poll.VoteCount.single(count) - } else { - return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) - } + let poll = (toot.reblog ?? toot).poll + configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID) + if let poll = poll { + ManagedObjectObserver.observe(object: poll) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case let .update(object) = change.changeType, + let newPoll = object as? Poll else { return } + StatusSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID) } - }() - - let managedObjectContext = toot.managedObjectContext! - cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( - for: cell.statusView.pollTableView, - managedObjectContext: managedObjectContext - ) - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - let pollItems = poll.options - .sorted(by: { $0.index.intValue < $1.index.intValue }) - .map { option -> PollItem in - let isVoted = (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - let attribute = PollItem.Attribute(isOptionVoted: isVoted) - let option = PollItem.opion(objectID: option.objectID, attribute: attribute) - return option - } - snapshot.appendItems(pollItems, toSection: .main) - cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - } else { - cell.statusView.pollTableView.isHidden = true - cell.statusView.pollStatusStackView.isHidden = true - cell.statusView.pollVoteButton.isHidden = true + .store(in: &cell.disposeBag) } - + // toolbar let replyCountTitle: String = { let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 @@ -244,6 +215,104 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configure( + cell: StatusTableViewCell, + timestampUpdatePublisher: AnyPublisher, + poll: Poll?, + requestUserID: String + ) { + guard let poll = poll, + let managedObjectContext = poll.managedObjectContext else { + cell.statusView.pollTableView.isHidden = true + cell.statusView.pollStatusStackView.isHidden = true + cell.statusView.pollVoteButton.isHidden = true + return + } + + cell.statusView.pollTableView.isHidden = false + cell.statusView.pollStatusStackView.isHidden = false + cell.statusView.pollVoteButton.isHidden = !poll.multiple + cell.statusView.pollVoteCountLabel.text = { + if poll.multiple { + let count = poll.votersCount?.intValue ?? 0 + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoterCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) + } + } else { + let count = poll.votesCount.intValue + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoteCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) + } + } + }() + if poll.expired { + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed + } else if let expiresAt = poll.expiresAt { + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + cell.pollCountdownSubscription = timestampUpdatePublisher + .sink { _ in + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + } + } else { + assertionFailure() + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = "-" + } + + cell.statusView.pollTableView.allowsSelection = !poll.expired + cell.statusView.pollTableView.allowsMultipleSelection = poll.multiple + + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.pollTableView, + managedObjectContext: managedObjectContext + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + } + let isPollVoted = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let pollItems = poll.options + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .map { option -> PollItem in + let attribute: PollItem.Attribute = { + let selectState: PollItem.Attribute.SelectState = { + if isPollVoted { + guard !votedOptions.isEmpty else { + return .none + } + return votedOptions.contains(option) ? .on : .off + } else if poll.expired { + return .none + } else { + return .off + } + }() + let voteState: PollItem.Attribute.VoteState = { + guard isPollVoted else { return .hidden } + let percentage: Double = { + guard poll.votesCount.intValue > 0 else { return 0.0 } + return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) + }() + let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) + return .reveal(voted: voted, percentage: percentage) + }() + return PollItem.Attribute(selectState: selectState, voteState: voteState) + }() + let option = PollItem.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + } + } extension StatusSection { diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 08507ed9d..d65991fe2 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -30,6 +30,10 @@ internal enum Asset { } internal enum Colors { internal enum Background { + internal enum Poll { + internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") + internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") + } internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 190657e94..c352473d2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -69,6 +69,8 @@ internal enum L10n { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } internal enum Poll { + /// Closed + internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") /// %@ left internal static func timeLeft(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.Poll.TimeLeft", String(describing: p1)) diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json new file mode 100644 index 000000000..78cde95fb --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/disabled.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json new file mode 100644 index 000000000..2e1ce5f3a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/Poll/highlight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a9480500f..6071cbee3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; "Common.Controls.Status.Poll.Vote" = "Vote"; "Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift new file mode 100644 index 000000000..30c9e22ca --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift @@ -0,0 +1,137 @@ +// +// VoteProgressStripView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import UIKit +import Combine + +final class VoteProgressStripView: UIView { + + var disposeBag = Set() + + private lazy var stripLayer: CAShapeLayer = { + let shapeLayer = CAShapeLayer() + shapeLayer.lineCap = .round + shapeLayer.fillColor = tintColor.cgColor + shapeLayer.strokeColor = UIColor.clear.cgColor + return shapeLayer + }() + + let progressMaskLayer: CAShapeLayer = { + let shapeLayer = CAShapeLayer() + shapeLayer.fillColor = UIColor.red.cgColor + return shapeLayer + }() + + let progress = CurrentValueSubject(0.0) + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension VoteProgressStripView { + + private func _init() { + updateLayerPath() + + layer.addSublayer(stripLayer) + + progress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + self.updateLayerPath() + } + .store(in: &disposeBag) + } + + override func layoutSubviews() { + super.layoutSubviews() + updateLayerPath() + } + +} + +extension VoteProgressStripView { + private func updateLayerPath() { + guard bounds != .zero else { return } + + stripLayer.frame = bounds + stripLayer.fillColor = tintColor.cgColor + + stripLayer.path = { + let path = UIBezierPath(roundedRect: bounds, cornerRadius: 0) + return path.cgPath + }() + + + progressMaskLayer.path = { + var rect = bounds + let newWidth = progress.value * rect.width + let widthChanged = rect.width - newWidth + rect.size.width = newWidth + switch UIApplication.shared.userInterfaceLayoutDirection { + case .rightToLeft: + rect.origin.x += widthChanged + default: + break + } + let path = UIBezierPath(rect: rect) + return path.cgPath + }() + stripLayer.mask = progressMaskLayer + } + +} + + +#if DEBUG +import SwiftUI + +struct VoteProgressStripView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + VoteProgressStripView() + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = VoteProgressStripView() + bar.tintColor = .white + bar.progress.value = 0.5 + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = VoteProgressStripView() + bar.tintColor = .white + bar.progress.value = 1.0 + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + } + } + +} +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 1da1246b1..32f59964c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -16,9 +16,15 @@ final class PollOptionTableViewCell: UITableViewCell { static let checkmarkImageSize = CGSize(width: 26, height: 26) private var viewStateDisposeBag = Set() - private(set) var pollState: PollState = .off + var selectState: PollItem.Attribute.SelectState = .off + var voteState: PollItem.Attribute.VoteState? let roundedBackgroundView = UIView() + let voteProgressStripView: VoteProgressStripView = { + let view = VoteProgressStripView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() let checkmarkBackgroundView: UIView = { let view = UIView() @@ -43,6 +49,8 @@ final class PollOptionTableViewCell: UITableViewCell { return label }() + let optionLabelMiddlePaddingView = UIView() + let optionPercentageLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 13, weight: .regular) @@ -64,16 +72,26 @@ final class PollOptionTableViewCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) + + guard let voteState = voteState else { return } + switch voteState { + case .hidden: + let color = Asset.Colors.Background.systemGroupedBackground.color + self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + case .reveal: + break + } } override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) - switch pollState { - case .off, .none: + guard let voteState = voteState else { return } + switch voteState { + case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color - case .on: + case .reveal: break } } @@ -97,6 +115,15 @@ extension PollOptionTableViewCell { roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), ]) + voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(voteProgressStripView) + NSLayoutConstraint.activate([ + voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), + voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), + voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), + voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), + ]) + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false roundedBackgroundView.addSubview(checkmarkBackgroundView) NSLayoutConstraint.activate([ @@ -123,23 +150,32 @@ extension PollOptionTableViewCell { optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), ]) + optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) + NSLayoutConstraint.activate([ + optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor), + optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), + ]) + optionLabelMiddlePaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal) + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false roundedBackgroundView.addSubview(optionPercentageLabel) NSLayoutConstraint.activate([ - optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor, constant: 8), + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), ]) optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) - optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - - configure(state: .none) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) } override func layoutSubviews() { super.layoutSubviews() updateCornerRadius() + updateTextAppearance() } private func updateCornerRadius() { @@ -152,41 +188,35 @@ extension PollOptionTableViewCell { checkmarkBackgroundView.layer.cornerCurve = .circular } -} - -extension PollOptionTableViewCell { - - enum PollState { - case none - case off - case on - } - - func configure(state: PollState) { - switch state { - case .none: - checkmarkBackgroundView.backgroundColor = .clear - checkmarkImageView.isHidden = true - optionPercentageLabel.isHidden = true + func updateTextAppearance() { + guard let voteState = voteState else { optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() - case .off: - checkmarkBackgroundView.backgroundColor = .systemBackground - checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor - checkmarkBackgroundView.layer.borderWidth = 1 - checkmarkImageView.isHidden = true - optionPercentageLabel.isHidden = true - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() - case .on: - checkmarkBackgroundView.backgroundColor = .systemBackground - checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor - checkmarkBackgroundView.layer.borderWidth = 0 - checkmarkImageView.isHidden = false - optionPercentageLabel.isHidden = false - optionLabel.textColor = .white - optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + return } + + switch voteState { + case .hidden: + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + case .reveal(_, let percentage): + if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { + optionLabel.textColor = .white + optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } else { + optionLabel.textColor = Asset.Colors.Label.primary.color + optionLabel.layer.removeShadow() + } + + if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.maxX { + optionPercentageLabel.textColor = .white + optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + } else { + optionPercentageLabel.textColor = Asset.Colors.Label.primary.color + optionPercentageLabel.layer.removeShadow() + } + } + } } @@ -205,13 +235,13 @@ struct PollTableViewCell_Previews: PreviewProvider { .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configure(state: .off) + PollSection.configure(cell: cell, selectState: .off) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) UIViewPreview() { let cell = PollOptionTableViewCell() - cell.configure(state: .on) + PollSection.configure(cell: cell, selectState: .on) return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 1c45bfe2d..2a62d1a40 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -27,6 +27,7 @@ final class StatusTableViewCell: UITableViewCell { weak var delegate: StatusTableViewCellDelegate? var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? var observations = Set() let statusView = StatusView() diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index 6868c668f..79fad947e 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -56,7 +56,8 @@ extension APIService.CoreData { let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil return PollOption.insert(into: managedObjectContext, property: PollOption.Property(index: i, title: option.title, votesCount: option.votesCount, networkDate: networkDate), votedBy: votedBy) } - let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), options: options) + let votedBy: MastodonUser? = (poll.voted ?? false) ? requestMastodonUser : nil + let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options) return object } let metions = entity.mentions?.compactMap { mention -> Mention in @@ -116,21 +117,8 @@ extension APIService.CoreData { guard networkDate > toot.updatedAt else { return } // merge poll - if let poll = entity.poll, let oldPoll = toot.poll, poll.options.count == oldPoll.options.count { - oldPoll.update(expiresAt: poll.expiresAt) - oldPoll.update(expired: poll.expired) - oldPoll.update(votesCount: poll.votesCount) - oldPoll.update(votersCount: poll.votersCount) - - let oldOptions = oldPoll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) - for (i, (option, oldOption)) in zip(poll.options, oldOptions).enumerated() { - let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil - oldOption.update(votesCount: option.votesCount) - votedBy.flatMap { oldOption.update(votedBy: $0) } - oldOption.didUpdate(at: networkDate) - } - - oldPoll.didUpdate(at: networkDate) + if let poll = toot.poll, let entity = entity.poll { + merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } // merge metrics @@ -188,12 +176,15 @@ extension APIService.CoreData { poll.update(expired: entity.expired) poll.update(votesCount: entity.votesCount) poll.update(votersCount: entity.votersCount) + requestMastodonUser.flatMap { + poll.update(voted: entity.voted ?? false, by: $0) + } let oldOptions = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) for (i, (optionEntity, option)) in zip(entity.options, oldOptions).enumerated() { - let votedBy: MastodonUser? = (entity.ownVotes ?? []).contains(i) ? requestMastodonUser : nil + let voted: Bool = (entity.ownVotes ?? []).contains(i) option.update(votesCount: optionEntity.votesCount) - votedBy.flatMap { option.update(votedBy: $0) } + requestMastodonUser.flatMap { option.update(voted: voted, by: $0) } option.didUpdate(at: networkDate) } From ba283bbdcb40c10508f999682543d4c03d028a10 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 10:51:49 +0800 Subject: [PATCH 032/400] fix: appVersion not set issue --- .../mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist | 4 ++-- Mastodon/Supporting Files/AppDelegate.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index b1a7a744c..bc78dfa4b 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -17,12 +17,12 @@ Mastodon - Release.xcscheme_^#shared#^_ orderHint - 1 + 2 Mastodon.xcscheme_^#shared#^_ orderHint - 0 + 1 SuppressBuildableAutocreation diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index cfac7f1a5..00d839b51 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -13,11 +13,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let appContext = AppContext() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") + + return true } // MARK: UISceneSession Lifecycle From 2ed2a7d8a1559a18af5fd113d77630a56cd43062 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 15:29:46 +0800 Subject: [PATCH 033/400] fix: make sign up error i18n display for each text filed. Fix memory leaking issue for pick server scene --- Localization/app.json | 63 +++--- Mastodon.xcodeproj/project.pbxproj | 20 +- .../Mastodon+Entidy+ErrorDetailReason.swift | 99 --------- .../Mastodon+Entity+Error+Detail.swift | 112 ++++++++++ .../MastodonSDK/Mastodon+Entity+Error.swift | 41 ++++ Mastodon/Extension/UIAlertController.swift | 41 ---- Mastodon/Generated/Strings.swift | 120 +++++----- .../Resources/en.lproj/Localizable.strings | 44 ++-- .../TableViewCell/PickServerCell.swift | 58 +++-- ...astodonRegisterViewController+Avatar.swift | 10 +- .../MastodonRegisterViewController.swift | 208 +++++++++++------- .../Register/MastodonRegisterViewModel.swift | 100 ++++++--- .../API/Error/Mastodon+API+Error.swift | 24 -- ...ift => Mastodon+Entity+Error+Detail.swift} | 98 +++++---- .../Entity/Mastodon+Entity+Error.swift | 4 +- 15 files changed, 588 insertions(+), 454 deletions(-) delete mode 100644 Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift rename MastodonSDK/Sources/MastodonSDK/Entity/{Mastodon+Entity+ErrorDetail.swift => Mastodon+Entity+Error+Detail.swift} (57%) diff --git a/Localization/app.json b/Localization/app.json index 43cdf01ad..920a8abe7 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -1,31 +1,5 @@ { "common": { - "errors": { - "item": { - "username": "username", - "email": "email", - "password": "password", - "agreement": "agreement", - "locale": "locale", - "reason": "reason" - }, - "itemDetail": { - "email_invalid": "This is not a valid e-mail address", - "username_invalid": "Username must only contain alphanumeric characters and underscores", - "password_too_shrot": "password is too short (must be at least 8 characters)", - "username_too_long": "username is too long (can't be longer than 30 characters)" - }, - "ERR_BLOCKED": "contains a disallowed e-mail provider", - "ERR_UNREACHABLE": "does not seem to exist", - "ERR_TAKEN": "is already in use", - "ERR_RESERVED": "is a reserved keyword", - "ERR_ACCEPTED": "must be accepted", - "ERR_BLANK": "is required", - "ERR_INVALID": "is invalid", - "ERR_TOO_LONG": "is too long", - "ERR_TOO_SHORT": "is too short", - "ERR_INCLUSION": "is not a supported value" - }, "alerts": { "sign_up_failure": { "title": "Sign Up Failure" @@ -33,7 +7,6 @@ "server_error": { "title": "Server Error" } - }, "controls": { "actions": { @@ -108,14 +81,40 @@ }, "password": { "placeholder": "password", - "hint": "Your password needs at least Eight characters" + "hint": "Your password needs at least eight characters" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Why do you want to join?" } }, - "success": "Success", - "check_email": "Regsiter request sent. Please check your email." + "error": { + "item": { + "username": "Username", + "email": "Email", + "password": "Password", + "agreement": "Agreement", + "locale": "Locale", + "reason": "Reason" + }, + "reason": { + "blocked": "%s contains a disallowed e-mail provider", + "unreachable": "%s does not seem to exist", + "taken": "%s is already in use", + "reserved": "%s is a reserved keyword", + "accepted": "%s must be accepted", + "blank": "%s is required", + "invalid": "%s is invalid", + "too_long": "%s is too long", + "too_short": "%s is too short", + "inclusion": "%s is not a supported value" + }, + "special": { + "username_invalid": "Username must only contain alphanumeric characters and underscores.", + "username_too_long": "Username is too long (can't be longer than 30 characters).", + "email_invalid": "This is not a valid e-mail address.", + "password_too_shrot": "Password is too short (must be at least 8 characters)." + } + } }, "server_rules": { "title": "Some ground rules.", @@ -151,4 +150,4 @@ "title": "Public" } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2a7c70420..f66c14c40 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -55,7 +55,7 @@ 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */; }; + 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -125,6 +125,7 @@ DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -264,7 +265,7 @@ 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entidy+ErrorDetailReason.swift"; sourceTree = ""; }; + 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -343,6 +344,7 @@ DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -885,6 +887,15 @@ path = NavigationController; sourceTree = ""; }; + DB6C8C0525F0921200AAA452 /* MastodonSDK */ = { + isa = PBXGroup; + children = ( + DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */, + 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, + ); + path = MastodonSDK; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1001,6 +1012,7 @@ isa = PBXGroup; children = ( DB084B5125CBC56300F898ED /* CoreDataStack */, + DB6C8C0525F0921200AAA452 /* MastodonSDK */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, @@ -1015,7 +1027,6 @@ DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, - 2D650FAA25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, ); path = Extension; @@ -1505,6 +1516,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, @@ -1517,7 +1529,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, - 2D650FAB25ECDC9300851B58 /* Mastodon+Entidy+ErrorDetailReason.swift in Sources */, + 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, diff --git a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift b/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift deleted file mode 100644 index cc1a47907..000000000 --- a/Mastodon/Extension/Mastodon+Entidy+ErrorDetailReason.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// Mastodon+Entity+ErrorDetailReason.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/1. -// -import MastodonSDK - -extension Mastodon.Entity.ErrorDetailReason { - func localizedDescription() -> String { - switch self.error { - case .ERR_BLOCKED: - return L10n.Common.Errors.errBlocked - case .ERR_UNREACHABLE: - return L10n.Common.Errors.errUnreachable - case .ERR_TAKEN: - return L10n.Common.Errors.errTaken - case .ERR_RESERVED: - return L10n.Common.Errors.errReserved - case .ERR_ACCEPTED: - return L10n.Common.Errors.errAccepted - case .ERR_BLANK: - return L10n.Common.Errors.errBlank - case .ERR_INVALID: - return L10n.Common.Errors.errInvalid - case .ERR_TOO_LONG: - return L10n.Common.Errors.errTooLong - case .ERR_TOO_SHORT: - return L10n.Common.Errors.errTooShort - case .ERR_INCLUSION: - return L10n.Common.Errors.errInclusion - case ._other: - return self.errorDescription ?? "" - } - } -} - -extension Mastodon.Entity.ErrorDetail { - func localizedDescription() -> String { - var messages: [String?] = [] - - if let username = self.username, !username.isEmpty { - let errors = username.map { errorDetailReason -> String in - switch errorDetailReason.error { - case .ERR_INVALID: - return L10n.Common.Errors.Itemdetail.usernameInvalid - case .ERR_TOO_LONG: - return L10n.Common.Errors.Itemdetail.usernameTooLong - default: - return L10n.Common.Errors.Item.username + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - - if let email = self.email, !email.isEmpty { - let errors = email.map { errorDetailReason -> String in - if errorDetailReason.error == .ERR_INVALID { - return L10n.Common.Errors.Itemdetail.emailInvalid - } else { - return L10n.Common.Errors.Item.email + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - if let password = self.password,!password.isEmpty { - let errors = password.map { errorDetailReason -> String in - if errorDetailReason.error == .ERR_TOO_SHORT { - return L10n.Common.Errors.Itemdetail.passwordTooShrot - } else { - return L10n.Common.Errors.Item.password + " " + errorDetailReason.localizedDescription() - } - } - messages.append(contentsOf: errors) - } - if let agreement = self.agreement, !agreement.isEmpty { - let errors = agreement.map { - L10n.Common.Errors.Item.agreement + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - if let locale = self.locale, !locale.isEmpty { - let errors = locale.map { - L10n.Common.Errors.Item.locale + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - if let reason = self.reason, !reason.isEmpty { - let errors = reason.map { - L10n.Common.Errors.Item.reason + " " + $0.localizedDescription() - } - messages.append(contentsOf: errors) - } - let message = messages - .compactMap { $0 } - .joined(separator: ", ") - return message.capitalizingFirstLetter() - } -} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift new file mode 100644 index 000000000..1e993a8c3 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -0,0 +1,112 @@ +// +// Mastodon+Entity+ErrorDetailReason.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/1. +// + +import Foundation +import MastodonSDK + +extension Mastodon.Entity.Error.Detail: LocalizedError { + + public var failureReason: String? { + let reasons: [[String]] = [ + usernameErrorDescriptions, + emailErrorDescriptions, + passwordErrorDescriptions, + agreementErrorDescriptions, + localeErrorDescriptions, + reasonErrorDescriptions, + ] + + guard !reasons.isEmpty else { + return nil + } + + return reasons + .flatMap { $0 } + .joined(separator: "; ") + + } + +} + +extension Mastodon.Entity.Error.Detail { + + enum Item: String { + case username + case email + case password + case agreement + case locale + case reason + + var localized: String { + switch self { + case .username: return L10n.Scene.Register.Error.Item.username + case .email: return L10n.Scene.Register.Error.Item.email + case .password: return L10n.Scene.Register.Error.Item.password + case .agreement: return L10n.Scene.Register.Error.Item.agreement + case .locale: return L10n.Scene.Register.Error.Item.locale + case .reason: return L10n.Scene.Register.Error.Item.reason + } + } + } + + private static func localizeError(item: Item, for reason: Reason) -> String { + switch (item, reason.error) { + case (.username, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.usernameInvalid + case (.username, .ERR_TOO_LONG): + return L10n.Scene.Register.Error.Special.usernameTooLong + case (.email, .ERR_INVALID): + return L10n.Scene.Register.Error.Special.emailInvalid + case (.password, .ERR_TOO_SHORT): + return L10n.Scene.Register.Error.Special.passwordTooShrot + case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized) + case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized) + case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized) + case (_, .ERR_RESERVED): return L10n.Scene.Register.Error.Reason.reserved(item.localized) + case (_, .ERR_ACCEPTED): return L10n.Scene.Register.Error.Reason.accepted(item.localized) + case (_, .ERR_BLANK): return L10n.Scene.Register.Error.Reason.blank(item.localized) + case (_, .ERR_INVALID): return L10n.Scene.Register.Error.Reason.invalid(item.localized) + case (_, .ERR_TOO_LONG): return L10n.Scene.Register.Error.Reason.tooLong(item.localized) + case (_, .ERR_TOO_SHORT): return L10n.Scene.Register.Error.Reason.tooShort(item.localized) + case (_, .ERR_INCLUSION): return L10n.Scene.Register.Error.Reason.inclusion(item.localized) + case (_, ._other(let reason)): + assertionFailure("Needs handle new error description here") + return item.rawValue + " " + reason.description + } + } + + var usernameErrorDescriptions: [String] { + guard let username = username, !username.isEmpty else { return [] } + return username.map { Mastodon.Entity.Error.Detail.localizeError(item: .username, for: $0) } + } + + var emailErrorDescriptions: [String] { + guard let email = email, !email.isEmpty else { return [] } + return email.map { Mastodon.Entity.Error.Detail.localizeError(item: .email, for: $0) } + } + + var passwordErrorDescriptions: [String] { + guard let password = password, !password.isEmpty else { return [] } + return password.map { Mastodon.Entity.Error.Detail.localizeError(item: .password, for: $0) } + } + + var agreementErrorDescriptions: [String] { + guard let agreement = agreement, !agreement.isEmpty else { return [] } + return agreement.map { Mastodon.Entity.Error.Detail.localizeError(item: .agreement, for: $0) } + } + + var localeErrorDescriptions: [String] { + guard let locale = locale, !locale.isEmpty else { return [] } + return locale.map { Mastodon.Entity.Error.Detail.localizeError(item: .locale, for: $0) } + } + + var reasonErrorDescriptions: [String] { + guard let reason = reason, !reason.isEmpty else { return [] } + return reason.map { Mastodon.Entity.Error.Detail.localizeError(item: .reason, for: $0) } + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift new file mode 100644 index 000000000..de3fd2f32 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error.swift @@ -0,0 +1,41 @@ +// +// Mastodon+Entity+Error.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-4. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Error: LocalizedError { + + public var errorDescription: String? { + guard let mastodonError = mastodonError else { + return "HTTP \(httpResponseStatus.code)" + } + switch mastodonError { + case .generic(let error): + if let _ = error.details { + return nil // Duplicated with the details + } else { + return error.error + } + } + } + + public var failureReason: String? { + guard let mastodonError = mastodonError else { + return httpResponseStatus.reasonPhrase + } + switch mastodonError { + case .generic(let error): + if let details = error.details { + return details.failureReason + } else { + return error.errorDescription + } + } + } + +} diff --git a/Mastodon/Extension/UIAlertController.swift b/Mastodon/Extension/UIAlertController.swift index 755acc1ae..2b598f2a9 100644 --- a/Mastodon/Extension/UIAlertController.swift +++ b/Mastodon/Extension/UIAlertController.swift @@ -42,44 +42,3 @@ extension UIAlertController { ) } } - -extension UIAlertController { - convenience init( - for error: Mastodon.API.Error, - title: String?, - preferredStyle: UIAlertController.Style - ) { - let _title: String - let message: String? - switch error.mastodonError { - case .generic(let mastodonEntityError): - - if let title = title { - _title = title - } else { - _title = error.errorDescription ?? "Error" - } - var messages: [String?] = [] - if let details = mastodonEntityError.details { - message = details.localizedDescription() - } else { - messages.append(contentsOf: [ - error.failureReason, - error.recoverySuggestion - ]) - message = messages - .compactMap { $0 } - .joined(separator: " ") - } - default: - _title = "Internal Error" - message = error.localizedDescription - } - - self.init( - title: _title, - message: message, - preferredStyle: preferredStyle - ) - } -} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index dfd69f0c6..c14e7ce01 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -82,52 +82,6 @@ internal enum L10n { internal static let single = L10n.tr("Localizable", "Common.Countable.Photo.Single") } } - internal enum Errors { - /// must be accepted - internal static let errAccepted = L10n.tr("Localizable", "Common.Errors.ErrAccepted") - /// is required - internal static let errBlank = L10n.tr("Localizable", "Common.Errors.ErrBlank") - /// contains a disallowed e-mail provider - internal static let errBlocked = L10n.tr("Localizable", "Common.Errors.ErrBlocked") - /// is not a supported value - internal static let errInclusion = L10n.tr("Localizable", "Common.Errors.ErrInclusion") - /// is invalid - internal static let errInvalid = L10n.tr("Localizable", "Common.Errors.ErrInvalid") - /// is a reserved keyword - internal static let errReserved = L10n.tr("Localizable", "Common.Errors.ErrReserved") - /// is already in use - internal static let errTaken = L10n.tr("Localizable", "Common.Errors.ErrTaken") - /// is too long - internal static let errTooLong = L10n.tr("Localizable", "Common.Errors.ErrTooLong") - /// is too short - internal static let errTooShort = L10n.tr("Localizable", "Common.Errors.ErrTooShort") - /// does not seem to exist - internal static let errUnreachable = L10n.tr("Localizable", "Common.Errors.ErrUnreachable") - internal enum Item { - /// agreement - internal static let agreement = L10n.tr("Localizable", "Common.Errors.Item.Agreement") - /// email - internal static let email = L10n.tr("Localizable", "Common.Errors.Item.Email") - /// locale - internal static let locale = L10n.tr("Localizable", "Common.Errors.Item.Locale") - /// password - internal static let password = L10n.tr("Localizable", "Common.Errors.Item.Password") - /// reason - internal static let reason = L10n.tr("Localizable", "Common.Errors.Item.Reason") - /// username - internal static let username = L10n.tr("Localizable", "Common.Errors.Item.Username") - } - internal enum Itemdetail { - /// This is not a valid e-mail address - internal static let emailInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.EmailInvalid") - /// password is too short (must be at least 8 characters) - internal static let passwordTooShrot = L10n.tr("Localizable", "Common.Errors.Itemdetail.PasswordTooShrot") - /// Username must only contain alphanumeric characters and underscores - internal static let usernameInvalid = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameInvalid") - /// username is too long (can't be longer than 30 characters) - internal static let usernameTooLong = L10n.tr("Localizable", "Common.Errors.Itemdetail.UsernameTooLong") - } - } } internal enum Scene { @@ -172,12 +126,76 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title") } internal enum Register { - /// Regsiter request sent. Please check your email. - internal static let checkEmail = L10n.tr("Localizable", "Scene.Register.CheckEmail") - /// Success - internal static let success = L10n.tr("Localizable", "Scene.Register.Success") /// Tell us about you. internal static let title = L10n.tr("Localizable", "Scene.Register.Title") + internal enum Error { + internal enum Item { + /// Agreement + internal static let agreement = L10n.tr("Localizable", "Scene.Register.Error.Item.Agreement") + /// Email + internal static let email = L10n.tr("Localizable", "Scene.Register.Error.Item.Email") + /// Locale + internal static let locale = L10n.tr("Localizable", "Scene.Register.Error.Item.Locale") + /// Password + internal static let password = L10n.tr("Localizable", "Scene.Register.Error.Item.Password") + /// Reason + internal static let reason = L10n.tr("Localizable", "Scene.Register.Error.Item.Reason") + /// Username + internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") + } + internal enum Reason { + /// %@ must be accepted. + internal static func accepted(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) + } + /// %@ is required. + internal static func blank(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1)) + } + /// %@ contains a disallowed e-mail provider. + internal static func blocked(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1)) + } + /// %@ is not a supported value. + internal static func inclusion(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1)) + } + /// %@ is invalid. + internal static func invalid(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1)) + } + /// %@ is a reserved keyword. + internal static func reserved(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1)) + } + /// %@ is already in use. + internal static func taken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1)) + } + /// %@ is too long. + internal static func tooLong(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1)) + } + /// %@ is too short. + internal static func tooShort(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1)) + } + /// %@ does not seem to exist. + internal static func unreachable(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1)) + } + } + internal enum Special { + /// This is not a valid e-mail address. + internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid") + /// Password is too short (must be at least 8 characters). + internal static let passwordTooShrot = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShrot") + /// Username must only contain alphanumeric characters and underscores. + internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") + /// Username is too long (can't be longer than 30 characters). + internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") + } + } internal enum Input { internal enum DisplayName { /// display name @@ -192,7 +210,7 @@ internal enum L10n { internal static let registrationUserInviteRequest = L10n.tr("Localizable", "Scene.Register.Input.Invite.RegistrationUserInviteRequest") } internal enum Password { - /// Your password needs at least Eight characters + /// Your password needs at least eight characters internal static let hint = L10n.tr("Localizable", "Scene.Register.Input.Password.Hint") /// password internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.Password.Placeholder") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d333460e9..acdf89f98 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -23,26 +23,6 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; -"Common.Errors.ErrAccepted" = "must be accepted"; -"Common.Errors.ErrBlank" = "is required"; -"Common.Errors.ErrBlocked" = "contains a disallowed e-mail provider"; -"Common.Errors.ErrInclusion" = "is not a supported value"; -"Common.Errors.ErrInvalid" = "is invalid"; -"Common.Errors.ErrReserved" = "is a reserved keyword"; -"Common.Errors.ErrTaken" = "is already in use"; -"Common.Errors.ErrTooLong" = "is too long"; -"Common.Errors.ErrTooShort" = "is too short"; -"Common.Errors.ErrUnreachable" = "does not seem to exist"; -"Common.Errors.Item.Agreement" = "agreement"; -"Common.Errors.Item.Email" = "email"; -"Common.Errors.Item.Locale" = "locale"; -"Common.Errors.Item.Password" = "password"; -"Common.Errors.Item.Reason" = "reason"; -"Common.Errors.Item.Username" = "username"; -"Common.Errors.Itemdetail.EmailInvalid" = "This is not a valid e-mail address"; -"Common.Errors.Itemdetail.PasswordTooShrot" = "password is too short (must be at least 8 characters)"; -"Common.Errors.Itemdetail.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; -"Common.Errors.Itemdetail.UsernameTooLong" = "username is too long (can't be longer than 30 characters)"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; @@ -57,15 +37,33 @@ tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; "Scene.HomeTimeline.Title" = "Home"; "Scene.PublicTimeline.Title" = "Public"; -"Scene.Register.CheckEmail" = "Regsiter request sent. Please check your email."; +"Scene.Register.Error.Item.Agreement" = "Agreement"; +"Scene.Register.Error.Item.Email" = "Email"; +"Scene.Register.Error.Item.Locale" = "Locale"; +"Scene.Register.Error.Item.Password" = "Password"; +"Scene.Register.Error.Item.Reason" = "Reason"; +"Scene.Register.Error.Item.Username" = "Username"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted."; +"Scene.Register.Error.Reason.Blank" = "%@ is required."; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider."; +"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value."; +"Scene.Register.Error.Reason.Invalid" = "%@ is invalid."; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword."; +"Scene.Register.Error.Reason.Taken" = "%@ is already in use."; +"Scene.Register.Error.Reason.TooLong" = "%@ is too long."; +"Scene.Register.Error.Reason.TooShort" = "%@ is too short."; +"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist."; +"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address."; +"Scene.Register.Error.Special.PasswordTooShrot" = "Password is too short (must be at least 8 characters)."; +"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores."; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)."; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; -"Scene.Register.Input.Password.Hint" = "Your password needs at least Eight characters"; +"Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters"; "Scene.Register.Input.Password.Placeholder" = "password"; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; -"Scene.Register.Success" = "Success"; "Scene.Register.Title" = "Tell us about you."; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.Seeless" = "See Less"; diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index fef09b955..6e36651fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -59,7 +59,9 @@ class PickServerCell: UITableViewCell { return label }() - private var thumbImageView: UIImageView = { + private let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) + + private var thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -178,6 +180,12 @@ class PickServerCell: UITableViewCell { } } + override func prepareForReuse() { + super.prepareForReuse() + + thumbnailImageView.af.cancelImageRequest() + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -205,7 +213,7 @@ extension PickServerCell { // Always add the expandbox which contains elements only visible in expand mode containerView.addSubview(expandBox) - expandBox.addSubview(thumbImageView) + expandBox.addSubview(thumbnailImageView) expandBox.addSubview(infoStackView) expandBox.isHidden = true @@ -254,20 +262,29 @@ extension PickServerCell { expandBox.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 8), expandBox.bottomAnchor.constraint(equalTo: infoStackView.bottomAnchor).priority(.defaultHigh), - thumbImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), - thumbImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), - expandBox.trailingAnchor.constraint(equalTo: thumbImageView.trailingAnchor), - thumbImageView.heightAnchor.constraint(equalTo: thumbImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), + thumbnailImageView.topAnchor.constraint(equalTo: expandBox.topAnchor), + thumbnailImageView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), + expandBox.trailingAnchor.constraint(equalTo: thumbnailImageView.trailingAnchor), + thumbnailImageView.heightAnchor.constraint(equalTo: thumbnailImageView.widthAnchor, multiplier: 151.0 / 303.0).priority(.defaultHigh), infoStackView.leadingAnchor.constraint(equalTo: expandBox.leadingAnchor), expandBox.trailingAnchor.constraint(equalTo: infoStackView.trailingAnchor), - infoStackView.topAnchor.constraint(equalTo: thumbImageView.bottomAnchor, constant: 16), + infoStackView.topAnchor.constraint(equalTo: thumbnailImageView.bottomAnchor, constant: 16), expandButton.leadingAnchor.constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), containerView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: expandButton.trailingAnchor), containerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: expandButton.bottomAnchor), ]) + thumbnailActivityIdicator.translatesAutoresizingMaskIntoConstraints = false + thumbnailImageView.addSubview(thumbnailActivityIdicator) + NSLayoutConstraint.activate([ + thumbnailActivityIdicator.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor), + thumbnailActivityIdicator.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor), + ]) + thumbnailActivityIdicator.hidesWhenStopped = true + thumbnailActivityIdicator.stopAnimating() + NSLayoutConstraint.activate(collapseConstraints) domainLabel.setContentHuggingPriority(.required - 1, for: .vertical) @@ -301,6 +318,8 @@ extension PickServerCell { expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) + + updateThumbnail() } } @@ -334,18 +353,29 @@ extension PickServerCell { return html.text ?? serverInfo.description }() - let processor = RoundCornerImageProcessor(cornerRadius: 3) - thumbImageView.kf.indicatorType = .activity - thumbImageView.kf.setImage(with: URL(string: serverInfo.proxiedThumbnail ?? "")!, placeholder: UIImage.placeholder(color: Asset.Colors.lightBackground.color), options: [ - .processor(processor), - .scaleFactor(UIScreen.main.scale), - .transition(.fade(1)) - ]) langValueLabel.text = serverInfo.language.uppercased() usersValueLabel.text = parseUsersCount(serverInfo.totalUsers) categoryValueLabel.text = serverInfo.category.uppercased() } + private func updateThumbnail() { + guard let serverInfo = server else { return } + + thumbnailActivityIdicator.startAnimating() + thumbnailImageView.af.setImage( + withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.33), + completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .success, .failure: + self.thumbnailActivityIdicator.stopAnimating() + } + } + ) + } + private func parseUsersCount(_ usersCount: Int) -> String { switch usersCount { case 0..<1000: diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index bb4e3f3eb..a23585271 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -10,7 +10,8 @@ import Foundation import PhotosUI import UIKit -extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerViewControllerDelegate { +// MARK: - PHPickerViewControllerDelegate +extension MastodonRegisterViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { picker.dismiss(animated: true, completion: {}) @@ -44,13 +45,18 @@ extension MastodonRegisterViewController: CropViewControllerDelegate, PHPickerVi } } } +} +// MARK: - CropViewControllerDelegate +extension MastodonRegisterViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { self.viewModel.avatarImage.value = image - self.photoButton.setImage(image, for: .normal) + self.avatarButton.setImage(image, for: .normal) cropViewController.dismiss(animated: true, completion: nil) } +} +extension MastodonRegisterViewController { @objc func avatarButtonPressed(_ sender: UIButton) { self.present(imagePicker, animated: true, completion: nil) } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index ba515e2da..fbb952834 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -49,13 +49,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return label }() - let photoView: UIView = { + let avatarView: UIView = { let view = UIView() view.backgroundColor = .clear return view }() - let photoButton: UIButton = { + let avatarButton: UIButton = { let button = UIButton(type: .custom) let boldFont = UIFont.systemFont(ofSize: 42) let configuration = UIImage.SymbolConfiguration(font: boldFont) @@ -67,11 +67,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O button.layer.cornerRadius = 45 button.clipsToBounds = true - button.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) return button }() - let plusIcon: UIImageView = { + let plusIconImageView: UIImageView = { let icon = UIImageView() let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) @@ -105,22 +104,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - let usernameIsTakenLabel: UILabel = { + let usernameErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.lightDangerRed.color let font = UIFont.preferredFont(forTextStyle: .caption1) - let attributeString = NSMutableAttributedString() - - let errorImage = NSTextAttachment() - let configuration = UIImage.SymbolConfiguration(font: font) - errorImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(color) - let errorImageAttachment = NSAttributedString(attachment: errorImage) - attributeString.append(errorImageAttachment) - - let errorString = NSAttributedString(string: L10n.Common.Errors.Item.username + " " + L10n.Common.Errors.errTaken, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(errorString) - label.attributedText = attributeString - return label }() @@ -157,9 +144,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - let passwordCheckLabel: UILabel = { + let emailErrorPromptLabel: UILabel = { let label = UILabel() - label.numberOfLines = 0 + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -181,7 +169,21 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() - lazy var inviteTextField: UITextField = { + let passwordCheckLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + let passwordErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) + return label + }() + + + lazy var reasonTextField: UITextField = { let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no @@ -197,6 +199,13 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O return textField }() + let reasonErrorPromptLabel: UILabel = { + let label = UILabel() + let color = Asset.Colors.lightDangerRed.color + let font = UIFont.preferredFont(forTextStyle: .caption1) + return label + }() + let buttonContainer = UIView() let signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() @@ -217,20 +226,9 @@ extension MastodonRegisterViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } - - photoButton.publisher(for: \.isHighlighted, options: .new) - .receive(on: DispatchQueue.main) - .sink { [weak self] isHighlighted in - guard let self = self else { return } - let alpha: CGFloat = isHighlighted ? 0.8 : 1 - self.plusIcon.alpha = alpha - self.photoButton.alpha = alpha - } - .store(in: &disposeBag) - domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() - passwordCheckLabel.attributedText = viewModel.attributeStringForPassword() + passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: false) usernameTextField.rightView = domainLabel usernameTextField.rightViewMode = .always usernameTextField.delegate = self @@ -250,16 +248,40 @@ extension MastodonRegisterViewController { stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 26, right: 0) stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(largeTitleLabel) - stackView.addArrangedSubview(photoView) + stackView.addArrangedSubview(avatarView) stackView.addArrangedSubview(usernameTextField) - stackView.addArrangedSubview(usernameIsTakenLabel) stackView.addArrangedSubview(displayNameTextField) stackView.addArrangedSubview(emailTextField) stackView.addArrangedSubview(passwordTextField) stackView.addArrangedSubview(passwordCheckLabel) if viewModel.approvalRequired { - stackView.addArrangedSubview(inviteTextField) + stackView.addArrangedSubview(reasonTextField) } + + usernameErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(usernameErrorPromptLabel) + NSLayoutConstraint.activate([ + usernameErrorPromptLabel.topAnchor.constraint(equalTo: usernameTextField.bottomAnchor, constant: 6), + usernameErrorPromptLabel.leadingAnchor.constraint(equalTo: usernameTextField.leadingAnchor), + usernameErrorPromptLabel.trailingAnchor.constraint(equalTo: usernameTextField.trailingAnchor), + ]) + + emailErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(emailErrorPromptLabel) + NSLayoutConstraint.activate([ + emailErrorPromptLabel.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 6), + emailErrorPromptLabel.leadingAnchor.constraint(equalTo: emailTextField.leadingAnchor), + emailErrorPromptLabel.trailingAnchor.constraint(equalTo: emailTextField.trailingAnchor), + ]) + + passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(passwordErrorPromptLabel) + NSLayoutConstraint.activate([ + passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 6), + passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), + passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), + ]) + // scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ @@ -282,24 +304,24 @@ extension MastodonRegisterViewController { ]) // photoview - photoView.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(photoButton) + avatarView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarButton) NSLayoutConstraint.activate([ - photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), ]) - photoButton.translatesAutoresizingMaskIntoConstraints = false + avatarButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor), - photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor), + avatarButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), + avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), ]) - plusIcon.translatesAutoresizingMaskIntoConstraints = false - photoView.addSubview(plusIcon) + plusIconImageView.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(plusIconImageView) NSLayoutConstraint.activate([ - plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), - plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), + plusIconImageView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor), + plusIconImageView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) // textfield @@ -360,6 +382,16 @@ extension MastodonRegisterViewController { } }) .store(in: &disposeBag) + + avatarButton.publisher(for: \.isHighlighted, options: .new) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHighlighted in + guard let self = self else { return } + let alpha: CGFloat = isHighlighted ? 0.8 : 1 + self.plusIconImageView.alpha = alpha + self.avatarButton.alpha = alpha + } + .store(in: &disposeBag) viewModel.isRegistering .receive(on: DispatchQueue.main) @@ -376,6 +408,13 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.usernameErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.usernameErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.displayNameValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in @@ -390,12 +429,33 @@ extension MastodonRegisterViewController { self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) } .store(in: &disposeBag) + viewModel.emailErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.emailErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) viewModel.passwordValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid) + self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: validateState == .valid) + } + .store(in: &disposeBag) + viewModel.passwordErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.passwordErrorPromptLabel.attributedText = prompt + } + .store(in: &disposeBag) + viewModel.reasonErrorPrompt + .receive(on: DispatchQueue.main) + .sink { [weak self] prompt in + guard let self = self else { return } + self.reasonErrorPromptLabel.attributedText = prompt } .store(in: &disposeBag) @@ -407,37 +467,11 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) - viewModel.isUsernameTaken - .receive(on: DispatchQueue.main) - .sink { [weak self] isUsernameTaken in - guard let self = self else { return } - if isUsernameTaken { - self.usernameIsTakenLabel.isHidden = false - stackView.setCustomSpacing(6, after: self.usernameTextField) - stackView.setCustomSpacing(16, after: self.usernameIsTakenLabel) - } else { - self.usernameIsTakenLabel.isHidden = true - stackView.setCustomSpacing(40, after: self.usernameTextField) - } - } - .store(in: &disposeBag) viewModel.error - .compactMap { $0 } .receive(on: DispatchQueue.main) .sink { [weak self] error in guard let self = self else { return } guard let error = error as? Mastodon.API.Error else { return } - switch error.mastodonError { - case .generic(let mastodonEntityError): - if let usernameTakenError = mastodonEntityError.details?.username { - let isUsernameAvaliable = usernameTakenError.filter { errorDetailReason -> Bool in - errorDetailReason.error == .ERR_TAKEN - }.isEmpty - self.viewModel.isUsernameTaken.value = !isUsernameAvaliable - } - default: - break - } let alertController = UIAlertController(for: error, title: "Sign Up Failure", preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) @@ -486,34 +520,42 @@ extension MastodonRegisterViewController { .store(in: &disposeBag) if viewModel.approvalRequired { - inviteTextField.delegate = self + reasonTextField.delegate = self NSLayoutConstraint.activate([ - inviteTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + reasonTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + ]) + reasonErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addSubview(reasonErrorPromptLabel) + NSLayoutConstraint.activate([ + reasonErrorPromptLabel.topAnchor.constraint(equalTo: reasonTextField.bottomAnchor, constant: 6), + reasonErrorPromptLabel.leadingAnchor.constraint(equalTo: reasonTextField.leadingAnchor), + reasonErrorPromptLabel.trailingAnchor.constraint(equalTo: reasonTextField.trailingAnchor), ]) - viewModel.inviteValidateState + viewModel.reasonValidateState .receive(on: DispatchQueue.main) .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.inviteTextField, validateState: validateState) + self.setTextFieldValidAppearance(self.reasonTextField, validateState: validateState) } .store(in: &disposeBag) NotificationCenter.default - .publisher(for: UITextField.textDidChangeNotification, object: inviteTextField) + .publisher(for: UITextField.textDidChangeNotification, object: reasonTextField) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - self.viewModel.reason.value = self.inviteTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.viewModel.reason.value = self.reasonTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } .store(in: &disposeBag) } + avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - plusIcon.layer.cornerRadius = plusIcon.frame.width/2 - plusIcon.clipsToBounds = true + plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width/2 + plusIconImageView.clipsToBounds = true } } @@ -530,7 +572,7 @@ extension MastodonRegisterViewController: UITextFieldDelegate { viewModel.email.value = text case passwordTextField: viewModel.password.value = text - case inviteTextField: + case reasonTextField: viewModel.reason.value = text default: break diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index f0c11849a..8f03cb124 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -26,6 +26,11 @@ final class MastodonRegisterViewModel { let reason = CurrentValueSubject("") let avatarImage = CurrentValueSubject(nil) + let usernameErrorPrompt = CurrentValueSubject(nil) + let emailErrorPrompt = CurrentValueSubject(nil) + let passwordErrorPrompt = CurrentValueSubject(nil) + let reasonErrorPrompt = CurrentValueSubject(nil) + // output let approvalRequired: Bool let applicationAuthorization: Mastodon.API.OAuth.Authorization @@ -33,10 +38,8 @@ final class MastodonRegisterViewModel { let displayNameValidateState = CurrentValueSubject(.empty) let emailValidateState = CurrentValueSubject(.empty) let passwordValidateState = CurrentValueSubject(.empty) - let inviteValidateState = CurrentValueSubject(.empty) - - let isUsernameTaken = CurrentValueSubject(false) - + let reasonValidateState = CurrentValueSubject(.empty) + let isRegistering = CurrentValueSubject(false) let isAllValid = CurrentValueSubject(false) let error = CurrentValueSubject(nil) @@ -102,25 +105,43 @@ final class MastodonRegisterViewModel { guard !invite.isEmpty else { return .empty } return .valid } - .assign(to: \.value, on: inviteValidateState) + .assign(to: \.value, on: reasonValidateState) .store(in: &disposeBag) } + + error + .sink { [weak self] error in + guard let self = self else { return } + let error = error as? Mastodon.API.Error + let mastodonError = error?.mastodonError + if case let .generic(genericMastodonError) = mastodonError, + let details = genericMastodonError.details { + self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + self.reasonErrorPrompt.value = details.reasonErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } + } else { + self.usernameErrorPrompt.value = nil + self.emailErrorPrompt.value = nil + self.passwordErrorPrompt.value = nil + self.reasonErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + let publisherOne = Publishers.CombineLatest4( usernameValidateState.eraseToAnyPublisher(), displayNameValidateState.eraseToAnyPublisher(), emailValidateState.eraseToAnyPublisher(), passwordValidateState.eraseToAnyPublisher() - ).map { - $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid - } + ) + .map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid } Publishers.CombineLatest( publisherOne, - approvalRequired ? inviteValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() ) - .map { - return $0 && $1 - } + .map { $0 && $1 } .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } @@ -135,6 +156,7 @@ extension MastodonRegisterViewModel { } extension MastodonRegisterViewModel { + static func isValidEmail(_ email: String) -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" @@ -142,37 +164,47 @@ extension MastodonRegisterViewModel { return emailPred.evaluate(with: email) } - func attributeStringForUsername() -> NSAttributedString { - let resultAttributeString = NSMutableAttributedString() - let redImage = NSTextAttachment() - let font = UIFont.preferredFont(forTextStyle: .caption1) + static func checkmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { let configuration = UIImage.SymbolConfiguration(font: font) - redImage.image = UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)?.withTintColor(Asset.Colors.lightDangerRed.color) - let imageAttribute = NSAttributedString(attachment: redImage) - let stringAttribute = NSAttributedString(string: "This username is taken.", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) - resultAttributeString.append(imageAttribute) - resultAttributeString.append(stringAttribute) - return resultAttributeString + return UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)! + } + + static func xmarkImage(font: UIFont = .preferredFont(forTextStyle: .caption1)) -> UIImage { + let configuration = UIImage.SymbolConfiguration(font: font) + return UIImage(systemName: "xmark.octagon.fill", withConfiguration: configuration)! } - func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString { + static func attributedStringImage(with image: UIImage, tintColor: UIColor) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(tintColor) + return NSAttributedString(attachment: attachment) + } + + static func attributeStringForPassword(isValid: Bool) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) - let color = UIColor.black - let falseColor = UIColor.clear let attributeString = NSMutableAttributedString() - - attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) - let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + + let image = MastodonRegisterViewModel.checkmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: isValid ? .black : .clear)) + attributeString.append(NSAttributedString(string: " ")) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]) attributeString.append(eightCharactersDescription) return attributeString } - - func checkmarkImage(color: UIColor) -> NSAttributedString { - let checkmarkImage = NSTextAttachment() + + static func errorPromptAttributedString(for prompt: String) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) - let configuration = UIImage.SymbolConfiguration(font: font) - checkmarkImage.image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: configuration)?.withTintColor(color) - return NSAttributedString(attachment: checkmarkImage) + let attributeString = NSMutableAttributedString() + + let image = MastodonRegisterViewModel.xmarkImage(font: font) + attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color)) + attributeString.append(NSAttributedString(string: " ")) + + let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) + attributeString.append(promptAttributedString) + + return attributeString } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift index 05540feda..94d063c40 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Error/Mastodon+API+Error.swift @@ -34,27 +34,3 @@ extension Mastodon.API { } } - -extension Mastodon.API.Error: LocalizedError { - - public var errorDescription: String? { - guard let mastodonError = mastodonError else { - return "HTTP \(httpResponseStatus.code)" - } - switch mastodonError { - case .generic(let error): - return error.error - } - } - - public var failureReason: String? { - guard let mastodonError = mastodonError else { - return httpResponseStatus.reasonPhrase - } - switch mastodonError { - case .generic(let error): - return error.errorDescription - } - } - -} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift similarity index 57% rename from MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift rename to MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift index 7881aaa0d..ef7f1b640 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+ErrorDetail.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error+Detail.swift @@ -6,27 +6,70 @@ // import Foundation + extension Mastodon.Entity.Error { - /// ERR_BLOCKED When e-mail provider is not allowed - /// ERR_UNREACHABLE When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) - /// ERR_TAKEN When username or e-mail are already taken - /// ERR_RESERVED When a username is reserved, e.g. "webmaster" or "admin" - /// ERR_ACCEPTED When agreement has not been accepted - /// ERR_BLANK When a required attribute is blank - /// ERR_INVALID When an attribute is malformed, e.g. wrong characters or invalid e-mail address - /// ERR_TOO_LONG When an attribute is over the character limit - /// ERR_INCLUSION When an attribute is not one of the allowed values, e.g. unsupported locale - public enum SignUpError: RawRepresentable, Codable { + public struct Detail: Codable { + public let username: [Reason]? + public let email: [Reason]? + public let password: [Reason]? + public let agreement: [Reason]? + public let locale: [Reason]? + public let reason: [Reason]? + + enum CodingKeys: String, CodingKey { + case username + case email + case password + case agreement + case locale + case reason + } + } +} + + + +extension Mastodon.Entity.Error.Detail { + public struct Reason: Codable { + public let error: Error + public let description: String + + enum CodingKeys: String, CodingKey { + case error + case description + } + } +} + +extension Mastodon.Entity.Error.Detail.Reason { + /// - Since: 3.3.1 + /// - Version: 3.3.1 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://github.com/tootsuite/mastodon/pull/15803) + public enum Error: RawRepresentable, Codable { + /// When e-mail provider is not allowed case ERR_BLOCKED + /// When e-mail address does not resolve to any IP via DNS (MX, A, AAAA) case ERR_UNREACHABLE + /// When username or e-mail are already taken case ERR_TAKEN + /// When a username is reserved, e.g. "webmaster" or "admin" case ERR_RESERVED + /// When agreement has not been accepted case ERR_ACCEPTED + /// When a required attribute is blank case ERR_BLANK + /// When an attribute is malformed, e.g. wrong characters or invalid e-mail address case ERR_INVALID + /// When an attribute is over the character limit case ERR_TOO_LONG + /// When an attribute is under the character requirement case ERR_TOO_SHORT + /// When an attribute is not one of the allowed values, e.g. unsupported locale case ERR_INCLUSION + /// Not handled error case _other(String) public init?(rawValue: String) { @@ -65,38 +108,3 @@ extension Mastodon.Entity.Error { } } } -extension Mastodon.Entity { - public struct ErrorDetail: Codable { - public let username: [ErrorDetailReason]? - public let email: [ErrorDetailReason]? - public let password: [ErrorDetailReason]? - public let agreement: [ErrorDetailReason]? - public let locale: [ErrorDetailReason]? - public let reason: [ErrorDetailReason]? - - enum CodingKeys: String, CodingKey { - case username - case email - case password - case agreement - case locale - case reason - } - } - - public struct ErrorDetailReason: Codable { - public init(error: String, errorDescription: String?) { - self.error = Mastodon.Entity.Error.SignUpError(rawValue: error) ?? ._other(error) - self.errorDescription = errorDescription - } - - public let error: Mastodon.Entity.Error.SignUpError - public let errorDescription: String? - - - enum CodingKeys: String, CodingKey { - case error - case errorDescription = "description" - } - } -} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift index a36025745..daf47bbd7 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Error.swift @@ -13,13 +13,13 @@ extension Mastodon.Entity { /// - Since: 0.6.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/1/28 + /// 2021/3/4 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/error/) public struct Error: Codable { public let error: String public let errorDescription: String? - public let details: ErrorDetail? + public let details: Detail? enum CodingKeys: String, CodingKey { case error From 54292d6b6ab497b5b44d7bb96fa68d285430be8a Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 16:42:43 +0800 Subject: [PATCH 034/400] fix: typo. Remove period --- Localization/app.json | 8 ++--- .../Mastodon+Entity+Error+Detail.swift | 2 +- Mastodon/Generated/Strings.swift | 30 +++++++++---------- .../Resources/en.lproj/Localizable.strings | 28 ++++++++--------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 920a8abe7..9c438b568 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -109,10 +109,10 @@ "inclusion": "%s is not a supported value" }, "special": { - "username_invalid": "Username must only contain alphanumeric characters and underscores.", - "username_too_long": "Username is too long (can't be longer than 30 characters).", - "email_invalid": "This is not a valid e-mail address.", - "password_too_shrot": "Password is too short (must be at least 8 characters)." + "username_invalid": "Username must only contain alphanumeric characters and underscores", + "username_too_long": "Username is too long (can't be longer than 30 characters)", + "email_invalid": "This is not a valid e-mail address", + "password_too_short": "Password is too short (must be at least 8 characters)" } } }, diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift index 1e993a8c3..312e4e3f0 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Error+Detail.swift @@ -63,7 +63,7 @@ extension Mastodon.Entity.Error.Detail { case (.email, .ERR_INVALID): return L10n.Scene.Register.Error.Special.emailInvalid case (.password, .ERR_TOO_SHORT): - return L10n.Scene.Register.Error.Special.passwordTooShrot + return L10n.Scene.Register.Error.Special.passwordTooShort case (_, .ERR_BLOCKED): return L10n.Scene.Register.Error.Reason.blocked(item.localized) case (_, .ERR_UNREACHABLE): return L10n.Scene.Register.Error.Reason.unreachable(item.localized) case (_, .ERR_TAKEN): return L10n.Scene.Register.Error.Reason.taken(item.localized) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c14e7ce01..75a661fad 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -144,55 +144,55 @@ internal enum L10n { internal static let username = L10n.tr("Localizable", "Scene.Register.Error.Item.Username") } internal enum Reason { - /// %@ must be accepted. + /// %@ must be accepted internal static func accepted(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Accepted", String(describing: p1)) } - /// %@ is required. + /// %@ is required internal static func blank(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blank", String(describing: p1)) } - /// %@ contains a disallowed e-mail provider. + /// %@ contains a disallowed e-mail provider internal static func blocked(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Blocked", String(describing: p1)) } - /// %@ is not a supported value. + /// %@ is not a supported value internal static func inclusion(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Inclusion", String(describing: p1)) } - /// %@ is invalid. + /// %@ is invalid internal static func invalid(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Invalid", String(describing: p1)) } - /// %@ is a reserved keyword. + /// %@ is a reserved keyword internal static func reserved(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Reserved", String(describing: p1)) } - /// %@ is already in use. + /// %@ is already in use internal static func taken(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Taken", String(describing: p1)) } - /// %@ is too long. + /// %@ is too long internal static func tooLong(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooLong", String(describing: p1)) } - /// %@ is too short. + /// %@ is too short internal static func tooShort(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.TooShort", String(describing: p1)) } - /// %@ does not seem to exist. + /// %@ does not seem to exist internal static func unreachable(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Register.Error.Reason.Unreachable", String(describing: p1)) } } internal enum Special { - /// This is not a valid e-mail address. + /// This is not a valid e-mail address internal static let emailInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.EmailInvalid") - /// Password is too short (must be at least 8 characters). - internal static let passwordTooShrot = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShrot") - /// Username must only contain alphanumeric characters and underscores. + /// Password is too short (must be at least 8 characters) + internal static let passwordTooShort = L10n.tr("Localizable", "Scene.Register.Error.Special.PasswordTooShort") + /// Username must only contain alphanumeric characters and underscores internal static let usernameInvalid = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameInvalid") - /// Username is too long (can't be longer than 30 characters). + /// Username is too long (can't be longer than 30 characters) internal static let usernameTooLong = L10n.tr("Localizable", "Scene.Register.Error.Special.UsernameTooLong") } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index acdf89f98..0ab1c70e4 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -43,20 +43,20 @@ tap the link to confirm your account."; "Scene.Register.Error.Item.Password" = "Password"; "Scene.Register.Error.Item.Reason" = "Reason"; "Scene.Register.Error.Item.Username" = "Username"; -"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted."; -"Scene.Register.Error.Reason.Blank" = "%@ is required."; -"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider."; -"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value."; -"Scene.Register.Error.Reason.Invalid" = "%@ is invalid."; -"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword."; -"Scene.Register.Error.Reason.Taken" = "%@ is already in use."; -"Scene.Register.Error.Reason.TooLong" = "%@ is too long."; -"Scene.Register.Error.Reason.TooShort" = "%@ is too short."; -"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist."; -"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address."; -"Scene.Register.Error.Special.PasswordTooShrot" = "Password is too short (must be at least 8 characters)."; -"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores."; -"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)."; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; +"Scene.Register.Error.Reason.Blank" = "%@ is required"; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider"; +"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value"; +"Scene.Register.Error.Reason.Invalid" = "%@ is invalid"; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword"; +"Scene.Register.Error.Reason.Taken" = "%@ is already in use"; +"Scene.Register.Error.Reason.TooLong" = "%@ is too long"; +"Scene.Register.Error.Reason.TooShort" = "%@ is too short"; +"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist"; +"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address"; +"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)"; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; From 06aac878c8f371fc2bcb2710af73e8e3b757d66f Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 4 Mar 2021 18:53:29 +0800 Subject: [PATCH 035/400] feat: [WIP] make the vote poll logic works --- CoreDataStack/Entity/PollOption.swift | 2 +- Localization/app.json | 13 +- .../Diffiable/Section/StatusSection.swift | 10 +- Mastodon/Generated/Strings.swift | 12 ++ ...Provider+StatusTableViewCellDelegate.swift | 35 +++++ .../StatusProvider+UITableViewDelegate.swift | 7 +- .../Resources/en.lproj/Localizable.strings | 4 + .../View/Content/VoteProgressStripView.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 46 ++++++- .../APIService/APIService+APIError.swift | 11 +- .../Service/APIService/APIService+Poll.swift | 124 ++++++++++++++++++ .../API/Mastodon+API+Favorites.swift | 10 ++ .../MastodonSDK/API/Mastodon+API+Polls.swift | 56 +++++++- 13 files changed, 318 insertions(+), 16 deletions(-) diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index 14a076144..f9a3ce953 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -62,7 +62,7 @@ extension PollOption { self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).add(by) } } else { - if !(self.votedBy ?? Set()).contains(by) { + if (self.votedBy ?? Set()).contains(by) { self.mutableSetValue(forKey: #keyPath(PollOption.votedBy)).remove(by) } } diff --git a/Localization/app.json b/Localization/app.json index d1b0e3c5c..078b68bd0 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -27,13 +27,20 @@ "ERR_INCLUSION": "is not a supported value" }, "alerts": { + "common": { + "please_try_again": "Please try again.", + "please_try_again_later": "Please try again later." + }, "sign_up_failure": { "title": "Sign Up Failure" }, "server_error": { "title": "Server Error" + }, + "vote_failure": { + "title": "Vote Failure", + "poll_expired": "The poll has expired" } - }, "controls": { "actions": { @@ -125,7 +132,7 @@ "prompt_eight_characters": "Eight characters" }, "invite": { - "registration_user_invite_request": "Why do you want to join?" + "registration_user_invite_request": "Why do you want to join?" } }, "success": "Success", @@ -165,4 +172,4 @@ "title": "Public" } } -} +} \ No newline at end of file diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 38e6a2b68..a9c07202d 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -284,13 +284,13 @@ extension StatusSection { .map { option -> PollItem in let attribute: PollItem.Attribute = { let selectState: PollItem.Attribute.SelectState = { - if isPollVoted { - guard !votedOptions.isEmpty else { - return .none - } + // make isPollVoted check later to make only local change possible + if !votedOptions.isEmpty { return votedOptions.contains(option) ? .on : .off } else if poll.expired { return .none + } else if isPollVoted, votedOptions.isEmpty { + return .none } else { return .off } @@ -302,6 +302,8 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) +// let voted = true +// let percentage: Double = Double.random(in: 0..<1) return .reveal(voted: voted, percentage: percentage) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c352473d2..e7c411274 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -13,6 +13,12 @@ internal enum L10n { internal enum Common { internal enum Alerts { + internal enum Common { + /// Please try again. + internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") + /// Please try again later. + internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -21,6 +27,12 @@ internal enum L10n { /// Sign Up Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") } + internal enum VoteFailure { + /// The poll has expired + internal static let pollExpired = L10n.tr("Localizable", "Common.Alerts.VoteFailure.PollExpired") + /// Vote Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.VoteFailure.Title") + } } internal enum Controls { internal enum Actions { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 6ef4b5f94..6a4d2df62 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -39,6 +39,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } +// MARK: - MosciaImageViewContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { @@ -69,3 +70,37 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +// MARK: - PollTableView +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { + guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } + + guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + guard case let .opion(objectID, attribute) = item else { return } + guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } + + + if option.poll.multiple { + var choices: [Int] = [] + + } else { + context.apiService.vote( + pollObjectID: option.poll.objectID, + mastodonUserObjectID: activeMastodonAuthentication.user.objectID, + choices: [option.index.intValue] + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { pollID in + + } + .store(in: &context.disposeBag) + + } + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index ea222c763..93f627c09 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -33,7 +33,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { return nil } let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) - guard timeIntervalSinceUpdate > 60 else { + #if DEBUG + let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing + #else + let autoRefreshTimeInterval: TimeInterval = 60 + #endif + guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate) return nil } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 6071cbee3..db613c059 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,9 @@ +"Common.Alerts.Common.PleaseTryAgain" = "Please try again."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; +"Common.Alerts.VoteFailure.Title" = "Vote Failure"; "Common.Controls.Actions.Add" = "Add"; "Common.Controls.Actions.Back" = "Back"; "Common.Controls.Actions.Cancel" = "Cancel"; diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift index 30c9e22ca..51016da3e 100644 --- a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift +++ b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift @@ -51,7 +51,9 @@ extension VoteProgressStripView { .receive(on: DispatchQueue.main) .sink { [weak self] progress in guard let self = self else { return } - self.updateLayerPath() + UIView.animate(withDuration: 0.33) { + self.updateLayerPath() + } } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 2a62d1a40..e8500e05f 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,11 +13,15 @@ import CoreData import CoreDataStack protocol StatusTableViewCellDelegate: class { + var context: AppContext! { get} var managedObjectContext: NSManagedObjectContext { get } - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) + } final class StatusTableViewCell: UITableViewCell { @@ -110,6 +114,44 @@ extension StatusTableViewCell: UITableViewDelegate { return true } } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + guard let context = delegate?.context else { return nil } + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath), + case let .opion(objectID, _) = item, + let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { + return nil + } + let poll = option.poll + + // disallow select when: poll expired OR user voted remote OR user voted local + let userID = activeMastodonAuthenticationBox.userID + let didVotedRemote = (option.poll.votedBy ?? Set()).contains(where: { $0.id == userID }) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(userID) + } + let didVotedLocal = !votedOptions.isEmpty + guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { + return nil + } + + return indexPath + } else { + return indexPath + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if tableView === statusView.pollTableView { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath) + } + } + } // MARK: - StatusViewDelegate diff --git a/Mastodon/Service/APIService/APIService+APIError.swift b/Mastodon/Service/APIService/APIService+APIError.swift index 7fd29b6b3..37235c2cb 100644 --- a/Mastodon/Service/APIService/APIService+APIError.swift +++ b/Mastodon/Service/APIService/APIService+APIError.swift @@ -21,6 +21,8 @@ extension APIService { case badResponse case requestThrottle + case voteExpiredPoll + // Server API error case mastodonAPIError(Mastodon.API.Error) } @@ -44,6 +46,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Bad Request" case .badResponse: return "Bad Response" case .requestThrottle: return "Request Throttled" + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.title case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { guard error.httpResponseStatus != .ok else { @@ -62,6 +65,7 @@ extension APIService.APIError: LocalizedError { case .badRequest: return "Request invalid." case .badResponse: return "Response invalid." case .requestThrottle: return "Request too frequency." + case .voteExpiredPoll: return L10n.Common.Alerts.VoteFailure.pollExpired case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil @@ -73,9 +77,10 @@ extension APIService.APIError: LocalizedError { var helpAnchor: String? { switch errorReason { case .authenticationMissing: return "Please request after authenticated." - case .badRequest: return "Please try again." - case .badResponse: return "Please try again." - case .requestThrottle: return "Please try again later." + case .badRequest: return L10n.Common.Alerts.Common.pleaseTryAgain + case .badResponse: return L10n.Common.Alerts.Common.pleaseTryAgain + case .requestThrottle: return L10n.Common.Alerts.Common.pleaseTryAgainLater + case .voteExpiredPoll: return nil case .mastodonAPIError(let error): guard let responseError = error.mastodonError else { return nil diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 33944c227..7ee3bb029 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -69,3 +69,127 @@ extension APIService { } } + +extension APIService { + + /// vote local + /// # Note + /// Not mark the poll voted so that view model could know when to reveal the results + func vote( + pollObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + choices: [Int] + ) -> AnyPublisher { + var _targetPollID: Mastodon.Entity.Poll.ID? + var isPollExpired = false + var didVotedLocal = false + + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let poll = managedObjectContext.object(with: pollObjectID) as! Poll + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + + _targetPollID = poll.id + + if let expiresAt = poll.expiresAt, Date().timeIntervalSince(expiresAt) > 0 { + isPollExpired = true + poll.update(expired: true) + return + } + + let options = poll.options.sorted(by: { $0.index.intValue < $1.index.intValue }) + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id) + } + guard votedOptions.isEmpty else { + // if did voted. Do not allow vote again + didVotedLocal = true + return + } + for option in options { + let voted = choices.contains(option.index.intValue) + option.update(voted: voted, by: mastodonUser) + option.didUpdate(at: option.updatedAt) // trigger update without change anything + } + poll.didUpdate(at: poll.updatedAt) // trigger update without change anything + } + .tryMap { result in + guard !isPollExpired else { + throw APIError.explicit(APIError.ErrorReason.voteExpiredPoll) + } + guard !didVotedLocal else { + throw APIError.implicit(APIError.ErrorReason.badRequest) + } + switch result { + case .success: + guard let targetPollID = _targetPollID else { + throw APIError.implicit(.badRequest) + } + return targetPollID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + // send vote request to remote + func vote( + domain: String, + pollID: Mastodon.Entity.Poll.ID, + pollObjectID: NSManagedObjectID, + choices: [Int], + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + let query = Mastodon.API.Polls.VoteQuery(choices: choices) + return Mastodon.API.Polls.vote( + session: session, + domain: domain, + pollID: pollID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + guard let requestMastodonUser = _requestMastodonUser else { + assertionFailure() + return + } + guard let poll = managedObjectContext.object(with: pollObjectID) as? Poll else { return } + APIService.CoreData.merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 54a6c7f82..ce77a51d9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -9,6 +9,7 @@ import Combine import Foundation extension Mastodon.API.Favorites { + static func favoritesStatusesEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") } @@ -34,6 +35,8 @@ extension Mastodon.API.Favorites { /// /// Add a status to your favourites list / Remove a status from your favourites list /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -60,6 +63,8 @@ extension Mastodon.API.Favorites { /// /// View who favourited a given status. /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -85,6 +90,8 @@ extension Mastodon.API.Favorites { /// /// Using this endpoint to view the favourited list for user /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -104,9 +111,11 @@ extension Mastodon.API.Favorites { } .eraseToAnyPublisher() } + } public extension Mastodon.API.Favorites { + enum FavoriteKind { case create case destroy @@ -144,4 +153,5 @@ public extension Mastodon.API.Favorites { return items } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift index 6329a4403..8ed031413 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Polls.swift @@ -14,11 +14,18 @@ extension Mastodon.API.Polls { let pathComponent = "polls/" + pollID return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } + + static func votePollEndpointURL(domain: String, pollID: Mastodon.Entity.Poll.ID) -> URL { + let pathComponent = "polls/" + pollID + "/votes" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } /// View a poll /// /// Using this endpoint to view the poll of status /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 /// # Last Update /// 2021/3/3 /// # Reference @@ -28,7 +35,7 @@ extension Mastodon.API.Polls { /// - domain: Mastodon instance domain. e.g. "example.com" /// - pollID: id for poll /// - authorization: User token. Could be nil if status is public - /// - Returns: `AnyPublisher` contains `Server` nested in the response + /// - Returns: `AnyPublisher` contains `Poll` nested in the response public static func poll( session: URLSession, domain: String, @@ -48,4 +55,51 @@ extension Mastodon.API.Polls { .eraseToAnyPublisher() } + /// Vote on a poll + /// + /// Using this endpoint to vote an option of poll + /// + /// - Since: 2.8.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/4 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/polls/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - pollID: id for poll + /// - query: `VoteQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func vote( + session: URLSession, + domain: String, + pollID: Mastodon.Entity.Poll.ID, + query: VoteQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: votePollEndpointURL(domain: domain, pollID: pollID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Poll.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Polls { + public struct VoteQuery: Codable, PostQuery { + public let choices: [Int] + + public init(choices: [Int]) { + self.choices = choices + } + } } From 58c8eaabe826c103e0f524f61662524d3f907142 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 12:11:04 +0800 Subject: [PATCH 036/400] feat: add animation for progress bar value change --- Mastodon.xcodeproj/project.pbxproj | 16 +- Mastodon/Diffiable/Section/PollSection.swift | 2 +- .../Diffiable/Section/StatusSection.swift | 2 - .../View/Content/VoteProgressStripView.swift | 139 --------------- .../View/Control/StripProgressView.swift | 165 ++++++++++++++++++ .../PollOptionTableViewCell.swift | 4 +- 6 files changed, 180 insertions(+), 148 deletions(-) delete mode 100644 Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift create mode 100644 Mastodon/Scene/Share/View/Control/StripProgressView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 439696738..bac04cbbf 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -129,7 +129,7 @@ DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; - DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */; }; + DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -358,7 +358,7 @@ DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; - DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoteProgressStripView.swift; sourceTree = ""; }; + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -527,7 +527,6 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, - DB59F11725EFA35B001F1DAB /* VoteProgressStripView.swift */, ); path = Content; sourceTree = ""; @@ -684,6 +683,7 @@ 2D42FF8325C82245004A627A /* Button */, 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, + DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, @@ -1119,6 +1119,14 @@ path = ViewModel; sourceTree = ""; }; + DBA9B90325F1D4420012E7B6 /* Control */ = { + isa = PBXGroup; + children = ( + DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + ); + path = Control; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1523,7 +1531,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, - DB59F11825EFA35B001F1DAB /* VoteProgressStripView.swift in Sources */, + DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index eff868a79..54753a7a0 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -76,7 +76,7 @@ extension PollSection { cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.progress.send(CGFloat(percentage)) + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: true) } cell.voteState = state diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index a9c07202d..d442a23e3 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -302,8 +302,6 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) -// let voted = true -// let percentage: Double = Double.random(in: 0..<1) return .reveal(voted: voted, percentage: percentage) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) diff --git a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift b/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift deleted file mode 100644 index 51016da3e..000000000 --- a/Mastodon/Scene/Share/View/Content/VoteProgressStripView.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// VoteProgressStripView.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-3. -// - -import UIKit -import Combine - -final class VoteProgressStripView: UIView { - - var disposeBag = Set() - - private lazy var stripLayer: CAShapeLayer = { - let shapeLayer = CAShapeLayer() - shapeLayer.lineCap = .round - shapeLayer.fillColor = tintColor.cgColor - shapeLayer.strokeColor = UIColor.clear.cgColor - return shapeLayer - }() - - let progressMaskLayer: CAShapeLayer = { - let shapeLayer = CAShapeLayer() - shapeLayer.fillColor = UIColor.red.cgColor - return shapeLayer - }() - - let progress = CurrentValueSubject(0.0) - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension VoteProgressStripView { - - private func _init() { - updateLayerPath() - - layer.addSublayer(stripLayer) - - progress - .receive(on: DispatchQueue.main) - .sink { [weak self] progress in - guard let self = self else { return } - UIView.animate(withDuration: 0.33) { - self.updateLayerPath() - } - } - .store(in: &disposeBag) - } - - override func layoutSubviews() { - super.layoutSubviews() - updateLayerPath() - } - -} - -extension VoteProgressStripView { - private func updateLayerPath() { - guard bounds != .zero else { return } - - stripLayer.frame = bounds - stripLayer.fillColor = tintColor.cgColor - - stripLayer.path = { - let path = UIBezierPath(roundedRect: bounds, cornerRadius: 0) - return path.cgPath - }() - - - progressMaskLayer.path = { - var rect = bounds - let newWidth = progress.value * rect.width - let widthChanged = rect.width - newWidth - rect.size.width = newWidth - switch UIApplication.shared.userInterfaceLayoutDirection { - case .rightToLeft: - rect.origin.x += widthChanged - default: - break - } - let path = UIBezierPath(rect: rect) - return path.cgPath - }() - stripLayer.mask = progressMaskLayer - } - -} - - -#if DEBUG -import SwiftUI - -struct VoteProgressStripView_Previews: PreviewProvider { - - static var previews: some View { - Group { - UIViewPreview() { - VoteProgressStripView() - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - UIViewPreview() { - let bar = VoteProgressStripView() - bar.tintColor = .white - bar.progress.value = 0.5 - return bar - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - UIViewPreview() { - let bar = VoteProgressStripView() - bar.tintColor = .white - bar.progress.value = 1.0 - return bar - } - .frame(width: 100, height: 44) - .padding() - .background(Color.black) - .previewLayout(.sizeThatFits) - } - } - -} -#endif diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift new file mode 100644 index 000000000..ae3f86735 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -0,0 +1,165 @@ +// +// StripProgressView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-3. +// + +import os.log +import UIKit +import Combine + +private final class StripProgressLayer: CALayer { + + var tintColor: UIColor = .black + @NSManaged var progress: CGFloat + + override class func needsDisplay(forKey key: String) -> Bool { + switch key { + case "progress": + return true + default: + return super.needsDisplay(forKey: key) + } + } + + override func display() { + let progress = presentation()?.progress ?? self.progress + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + + UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) + guard let context = UIGraphicsGetCurrentContext() else { + assertionFailure() + return + } + context.clear(bounds) + + var rect = bounds + let newWidth = CGFloat(progress) * rect.width + let widthChanged = rect.width - newWidth + rect.size.width = newWidth + switch UIApplication.shared.userInterfaceLayoutDirection { + case .rightToLeft: + rect.origin.x += widthChanged + default: + break + } + let path = UIBezierPath(rect: rect) + context.setFillColor(tintColor.cgColor) + context.addPath(path.cgPath) + context.fillPath() + + contents = UIGraphicsGetImageFromCurrentImageContext()?.cgImage + UIGraphicsEndImageContext() + } + +} + +final class StripProgressView: UIView { + + var disposeBag = Set() + + private let stripProgressLayer: StripProgressLayer = { + let layer = StripProgressLayer() + return layer + }() + + override var tintColor: UIColor! { + didSet { + stripProgressLayer.tintColor = tintColor + setNeedsDisplay() + } + } + + func setProgress(_ progress: CGFloat, animated: Bool) { + stripProgressLayer.removeAnimation(forKey: "progressAnimationKey") + if animated { + let animation = CABasicAnimation(keyPath: "progress") + animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress + animation.toValue = progress + animation.duration = 0.33 + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + animation.isRemovedOnCompletion = true + stripProgressLayer.add(animation, forKey: "progressAnimationKey") + stripProgressLayer.progress = progress + } else { + stripProgressLayer.progress = progress + stripProgressLayer.setNeedsDisplay() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StripProgressView { + + private func _init() { + layer.addSublayer(stripProgressLayer) + updateLayerPath() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateLayerPath() + } + +} + +extension StripProgressView { + private func updateLayerPath() { + guard bounds != .zero else { return } + + stripProgressLayer.frame = bounds + stripProgressLayer.tintColor = tintColor + stripProgressLayer.setNeedsDisplay() + } +} + +#if DEBUG +import SwiftUI + +struct VoteProgressStripView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview() { + StripProgressView() + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(0.5, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + UIViewPreview() { + let bar = StripProgressView() + bar.tintColor = .white + bar.setProgress(1.0, animated: false) + return bar + } + .frame(width: 100, height: 44) + .padding() + .background(Color.black) + .previewLayout(.sizeThatFits) + } + } + +} +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 32f59964c..455e5fb9c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -20,8 +20,8 @@ final class PollOptionTableViewCell: UITableViewCell { var voteState: PollItem.Attribute.VoteState? let roundedBackgroundView = UIView() - let voteProgressStripView: VoteProgressStripView = { - let view = VoteProgressStripView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() view.tintColor = Asset.Colors.Background.Poll.highlight.color return view }() From 11cee6df357371bb612816c662ee16f0daee9fa1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 13:41:48 +0800 Subject: [PATCH 037/400] feat: implement single vote poll --- Mastodon/Diffiable/Item/Item.swift | 8 ++--- Mastodon/Diffiable/Item/PollItem.swift | 2 +- Mastodon/Diffiable/Section/PollSection.swift | 25 +++++++------- .../Diffiable/Section/StatusSection.swift | 33 +++++++++++++------ ...Provider+StatusTableViewCellDelegate.swift | 21 ++++++++++-- .../HomeTimelineViewModel+Diffable.swift | 4 +-- .../PublicTimelineViewModel+Diffable.swift | 4 +-- .../View/Control/StripProgressView.swift | 19 ++++++++--- .../PollOptionTableViewCell.swift | 11 +++---- .../Service/APIService/APIService+Poll.swift | 2 +- 10 files changed, 81 insertions(+), 48 deletions(-) diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..645dcd7a8 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -13,10 +13,10 @@ import MastodonSDK /// Note: update Equatable when change case enum Item { // timeline - case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) // normal list - case toot(objectID: NSManagedObjectID, attribute: StatusTimelineAttribute) + case toot(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) @@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute { } extension Item { - class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { + class StatusAttribute: Hashable, StatusContentWarningAttribute { var isStatusTextSensitive: Bool var isStatusSensitive: Bool @@ -42,7 +42,7 @@ extension Item { self.isStatusSensitive = isStatusSensitive } - static func == (lhs: Item.StatusTimelineAttribute, rhs: Item.StatusTimelineAttribute) -> Bool { + static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool { return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && lhs.isStatusSensitive == rhs.isStatusSensitive } diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index e4b0ff8df..006400f9e 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -24,7 +24,7 @@ extension PollItem { enum VoteState: Equatable, Hashable { case hidden - case reveal(voted: Bool, percentage: Double) + case reveal(voted: Bool, percentage: Double, animated: Bool) } var selectState: SelectState diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 54753a7a0..d7a7b43f1 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -24,7 +24,7 @@ extension PollSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PollOptionTableViewCell.self), for: indexPath) as! PollOptionTableViewCell managedObjectContext.performAndWait { let option = managedObjectContext.object(with: objectID) as! PollOption - PollSection.configure(cell: cell, pollOption: option, itemAttribute: attribute) + PollSection.configure(cell: cell, pollOption: option, pollItemAttribute: attribute) } return cell } @@ -35,12 +35,15 @@ extension PollSection { extension PollSection { static func configure( cell: PollOptionTableViewCell, - pollOption: PollOption, - itemAttribute: PollItem.Attribute + pollOption option: PollOption, + pollItemAttribute attribute: PollItem.Attribute ) { - cell.optionLabel.text = pollOption.title - configure(cell: cell, selectState: itemAttribute.selectState) - configure(cell: cell, voteState: itemAttribute.voteState) + cell.optionLabel.text = option.title + configure(cell: cell, selectState: attribute.selectState) + configure(cell: cell, voteState: attribute.voteState) + cell.attribute = attribute + cell.layoutIfNeeded() + cell.updateTextAppearance() } } @@ -64,24 +67,18 @@ extension PollSection { cell.checkmarkBackgroundView.isHidden = false cell.checkmarkImageView.isHidden = false } - - cell.selectState = state } static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { switch state { case .hidden: cell.optionPercentageLabel.isHidden = true - case .reveal(let voted, let percentage): + case .reveal(let voted, let percentage, let animated): cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: true) + cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } - cell.voteState = state - - cell.layoutIfNeeded() - cell.updateTextAppearance() } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index d442a23e3..3c15996d6 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -34,7 +34,7 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute) } cell.delegate = statusTableViewCellDelegate return cell @@ -45,7 +45,7 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusContentWarningAttribute: attribute) + StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute) } cell.delegate = statusTableViewCellDelegate return cell @@ -76,7 +76,7 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher, toot: Toot, requestUserID: String, - statusContentWarningAttribute: StatusContentWarningAttribute? + statusItemAttribute: Item.StatusAttribute ) { // set header cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil @@ -99,7 +99,7 @@ extension StatusSection { // set status text content warning let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusContentWarningAttribute?.isStatusTextSensitive ?? !spoilerText.isEmpty + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.contentWarningTitle.text = { @@ -153,13 +153,19 @@ extension StatusSection { } } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusContentWarningAttribute?.isStatusSensitive ?? (toot.reblog ?? toot).sensitive + let isStatusSensitive = statusItemAttribute.isStatusSensitive cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set poll let poll = (toot.reblog ?? toot).poll - configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: poll, requestUserID: requestUserID) + StatusSection.configure( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) if let poll = poll { ManagedObjectObserver.observe(object: poll) .sink { _ in @@ -167,7 +173,13 @@ extension StatusSection { } receiveValue: { change in guard case let .update(object) = change.changeType, let newPoll = object as? Poll else { return } - StatusSection.configure(cell: cell, timestampUpdatePublisher: timestampUpdatePublisher, poll: newPoll, requestUserID: requestUserID) + StatusSection.configure( + cell: cell, + poll: newPoll, + requestUserID: requestUserID, + updateProgressAnimated: true, + timestampUpdatePublisher: timestampUpdatePublisher + ) } .store(in: &cell.disposeBag) } @@ -218,9 +230,10 @@ extension StatusSection { static func configure( cell: StatusTableViewCell, - timestampUpdatePublisher: AnyPublisher, poll: Poll?, - requestUserID: String + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, let managedObjectContext = poll.managedObjectContext else { @@ -302,7 +315,7 @@ extension StatusSection { return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) }() let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) - return .reveal(voted: voted, percentage: percentage) + return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) }() return PollItem.Attribute(selectState: selectState, voteState: voteState) }() diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 6a4d2df62..d62fb6cca 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -75,6 +75,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } @@ -82,24 +83,38 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard case let .opion(objectID, attribute) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } + let domain = option.poll.toot.domain + let pollObjectID = option.poll.objectID if option.poll.multiple { var choices: [Int] = [] } else { + let choices = [option.index.intValue] context.apiService.vote( pollObjectID: option.poll.objectID, mastodonUserObjectID: activeMastodonAuthentication.user.objectID, choices: [option.index.intValue] ) + .handleEvents(receiveOutput: { _ in + // TODO: add haptic + }) + .flatMap { pollID -> AnyPublisher, Error> in + return self.context.apiService.vote( + domain: domain, + pollID: pollID, + pollObjectID: pollObjectID, + choices: choices, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } .receive(on: DispatchQueue.main) .sink { completion in - } receiveValue: { pollID in - + } receiveValue: { response in + print(response.value) } .store(in: &context.disposeBag) - } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index fffa4b7f7..4a34b922a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -73,7 +73,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { // that's will be the most fastest fetch because of upstream just update and no modify needs consider - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { guard case let .homeTimelineIndex(objectID, attribute) = item else { continue } @@ -88,7 +88,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index fa17319b4..d69da8f65 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -50,7 +50,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { return indexes.firstIndex(of: toot.id).map { index in (index, toot) } } .sorted { $0.0 < $1.0 } - var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusTimelineAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { guard case let .toot(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute @@ -63,7 +63,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusTimelineAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) + let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) if tootIDsWhichHasGap.contains(toot.id) { items.append(Item.publicMiddleLoader(tootID: toot.id)) diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift index ae3f86735..e6550ba3d 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -11,12 +11,15 @@ import Combine private final class StripProgressLayer: CALayer { + static let progressAnimationKey = "progressAnimationKey" + static let progressKey = "progress" + var tintColor: UIColor = .black @NSManaged var progress: CGFloat override class func needsDisplay(forKey key: String) -> Bool { switch key { - case "progress": + case StripProgressLayer.progressKey: return true default: return super.needsDisplay(forKey: key) @@ -24,7 +27,13 @@ private final class StripProgressLayer: CALayer { } override func display() { - let progress = presentation()?.progress ?? self.progress + let progress: CGFloat = { + guard animation(forKey: StripProgressLayer.progressAnimationKey) != nil else { + return self.progress + } + + return presentation()?.progress ?? self.progress + }() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) @@ -72,15 +81,15 @@ final class StripProgressView: UIView { } func setProgress(_ progress: CGFloat, animated: Bool) { - stripProgressLayer.removeAnimation(forKey: "progressAnimationKey") + stripProgressLayer.removeAnimation(forKey: StripProgressLayer.progressAnimationKey) if animated { - let animation = CABasicAnimation(keyPath: "progress") + let animation = CABasicAnimation(keyPath: StripProgressLayer.progressKey) animation.fromValue = stripProgressLayer.presentation()?.progress ?? stripProgressLayer.progress animation.toValue = progress animation.duration = 0.33 animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) animation.isRemovedOnCompletion = true - stripProgressLayer.add(animation, forKey: "progressAnimationKey") + stripProgressLayer.add(animation, forKey: StripProgressLayer.progressAnimationKey) stripProgressLayer.progress = progress } else { stripProgressLayer.progress = progress diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 455e5fb9c..7aa7ef41d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -16,8 +16,7 @@ final class PollOptionTableViewCell: UITableViewCell { static let checkmarkImageSize = CGSize(width: 26, height: 26) private var viewStateDisposeBag = Set() - var selectState: PollItem.Attribute.SelectState = .off - var voteState: PollItem.Attribute.VoteState? + var attribute: PollItem.Attribute? let roundedBackgroundView = UIView() let voteProgressStripView: StripProgressView = { @@ -73,7 +72,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -86,7 +85,7 @@ final class PollOptionTableViewCell: UITableViewCell { override func setHighlighted(_ highlighted: Bool, animated: Bool) { super.setHighlighted(highlighted, animated: animated) - guard let voteState = voteState else { return } + guard let voteState = attribute?.voteState else { return } switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color @@ -189,7 +188,7 @@ extension PollOptionTableViewCell { } func updateTextAppearance() { - guard let voteState = voteState else { + guard let voteState = attribute?.voteState else { optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() return @@ -199,7 +198,7 @@ extension PollOptionTableViewCell { case .hidden: optionLabel.textColor = Asset.Colors.Label.primary.color optionLabel.layer.removeShadow() - case .reveal(_, let percentage): + case .reveal(_, let percentage, _): if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { optionLabel.textColor = .white optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 7ee3bb029..350da97c8 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -135,7 +135,7 @@ extension APIService { .eraseToAnyPublisher() } - // send vote request to remote + /// send vote request to remote func vote( domain: String, pollID: Mastodon.Entity.Poll.ID, From d79666679a5cd933a5a62eaa267a4b8f7434ea4f Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 14:23:26 +0800 Subject: [PATCH 038/400] fix: poll option UI reuse issue --- Mastodon/Diffiable/Section/PollSection.swift | 3 +++ Mastodon/Diffiable/Section/StatusSection.swift | 12 ++++++++++-- .../View/TableviewCell/StatusTableViewCell.swift | 12 ++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index d7a7b43f1..45da63bde 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -73,9 +73,12 @@ extension PollSection { switch state { case .hidden: cell.optionPercentageLabel.isHidden = true + cell.voteProgressStripView.isHidden = true + cell.voteProgressStripView.setProgress(0.0, animated: false) case .reveal(let voted, let percentage, let animated): cell.optionPercentageLabel.isHidden = false cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.voteProgressStripView.isHidden = false cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3c15996d6..4863f4756 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -297,7 +297,7 @@ extension StatusSection { .map { option -> PollItem in let attribute: PollItem.Attribute = { let selectState: PollItem.Attribute.SelectState = { - // make isPollVoted check later to make only local change possible + // make isPollVoted check later to make the local change possible if !votedOptions.isEmpty { return votedOptions.contains(option) ? .on : .off } else if poll.expired { @@ -309,7 +309,15 @@ extension StatusSection { } }() let voteState: PollItem.Attribute.VoteState = { - guard isPollVoted else { return .hidden } + var needsReveal: Bool + if poll.expired { + needsReveal = true + } else if isPollVoted { + needsReveal = true + } else { + needsReveal = false + } + guard needsReveal else { return .hidden } let percentage: Double = { guard poll.votesCount.intValue > 0 else { return 0.0 } return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index e8500e05f..64ffb167b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -103,12 +103,16 @@ extension StatusTableViewCell { extension StatusTableViewCell: UITableViewDelegate { func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { + var pollID: String? + defer { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "") + } guard let item = diffableDataSource.itemIdentifier(for: indexPath), case let .opion(objectID, _) = item, let option = delegate?.managedObjectContext.object(with: objectID) as? PollOption else { return false } - + pollID = option.poll.id return !option.poll.expired } else { return true @@ -117,7 +121,10 @@ extension StatusTableViewCell: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + var pollID: String? + defer { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s. PollID: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription, pollID ?? "") + } guard let context = delegate?.context else { return nil } guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } @@ -127,6 +134,7 @@ extension StatusTableViewCell: UITableViewDelegate { return nil } let poll = option.poll + pollID = poll.id // disallow select when: poll expired OR user voted remote OR user voted local let userID = activeMastodonAuthenticationBox.userID From 0df1a57865fa9f8675ec514fb3cd763e339997b8 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 15:53:36 +0800 Subject: [PATCH 039/400] feat: implement multiple poll --- .../Diffiable/Section/StatusSection.swift | 21 +++--- ...Provider+StatusTableViewCellDelegate.swift | 70 +++++++++++++++++-- .../Scene/Share/View/Content/StatusView.swift | 12 +++- .../TableviewCell/StatusTableViewCell.swift | 30 ++++++-- .../Service/APIService/APIService+Poll.swift | 6 +- 5 files changed, 117 insertions(+), 22 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4863f4756..5f9d43ed5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -245,7 +245,6 @@ extension StatusSection { cell.statusView.pollTableView.isHidden = false cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteButton.isHidden = !poll.multiple cell.statusView.pollVoteCountLabel.text = { if poll.multiple { let count = poll.votersCount?.intValue ?? 0 @@ -279,7 +278,14 @@ extension StatusSection { } cell.statusView.pollTableView.allowsSelection = !poll.expired - cell.statusView.pollTableView.allowsMultipleSelection = poll.multiple + + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + } + let didVotedLocal = !votedOptions.isEmpty + let didVotedRemote = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + cell.statusView.pollVoteButton.isEnabled = didVotedLocal + cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( for: cell.statusView.pollTableView, @@ -288,21 +294,18 @@ extension StatusSection { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) - } - let isPollVoted = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let pollItems = poll.options .sorted(by: { $0.index.intValue < $1.index.intValue }) .map { option -> PollItem in let attribute: PollItem.Attribute = { let selectState: PollItem.Attribute.SelectState = { - // make isPollVoted check later to make the local change possible + // check didVotedRemote later to make the local change possible if !votedOptions.isEmpty { return votedOptions.contains(option) ? .on : .off } else if poll.expired { return .none - } else if isPollVoted, votedOptions.isEmpty { + } else if didVotedRemote, votedOptions.isEmpty { return .none } else { return .off @@ -312,7 +315,7 @@ extension StatusSection { var needsReveal: Bool if poll.expired { needsReveal = true - } else if isPollVoted { + } else if didVotedRemote { needsReveal = true } else { needsReveal = false diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index d62fb6cca..cd4e5160d 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -74,6 +74,45 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // MARK: - PollTableView extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + toot(for: cell, indexPath: nil) + .receive(on: DispatchQueue.main) + .setFailureType(to: Error.self) + .compactMap { toot -> AnyPublisher, Error>? in + guard let toot = (toot?.reblog ?? toot) else { return nil } + guard let poll = toot.poll else { return nil } + + let votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } + let choices = votedOptions.map { $0.index.intValue } + let domain = poll.toot.domain + + button.isEnabled = false + + return self.context.apiService.vote( + domain: domain, + pollID: poll.id, + pollObjectID: poll.objectID, + choices: choices, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: multiple vote fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + button.isEnabled = true + case .finished: + break + } + }, receiveValue: { response in + // do nothing + }) + .store(in: &context.disposeBag) + } + func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let activeMastodonAuthentication = context.authenticationService.activeMastodonAuthentication.value else { return } @@ -83,16 +122,37 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard case let .opion(objectID, attribute) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } - let domain = option.poll.toot.domain + let poll = option.poll let pollObjectID = option.poll.objectID + let domain = poll.toot.domain - if option.poll.multiple { - var choices: [Int] = [] - + if poll.multiple { + var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } + if votedOptions.contains(option) { + votedOptions.remove(option) + } else { + votedOptions.insert(option) + } + let choices = votedOptions.map { $0.index.intValue } + context.apiService.vote( + pollObjectID: option.poll.objectID, + mastodonUserObjectID: activeMastodonAuthentication.user.objectID, + choices: choices + ) + .handleEvents(receiveOutput: { _ in + // TODO: add haptic + }) + .receive(on: DispatchQueue.main) + .sink { completion in + // Do nothing + } receiveValue: { _ in + // Do nothing + } + .store(in: &context.disposeBag) } else { let choices = [option.index.intValue] context.apiService.vote( - pollObjectID: option.poll.objectID, + pollObjectID: pollObjectID, mastodonUserObjectID: activeMastodonAuthentication.user.objectID, choices: [option.index.intValue] ) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index f6095db07..c1f3cb3d0 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -13,6 +13,7 @@ import AlamofireImage protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) } final class StatusView: UIView { @@ -138,11 +139,12 @@ final class StatusView: UIView { }() let pollVoteButton: UIButton = { let button = HitTestExpandedButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .regular)) + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal) button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted) button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) + button.isEnabled = false return button }() @@ -350,6 +352,7 @@ extension StatusView { statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } } @@ -385,10 +388,17 @@ extension StatusView { } extension StatusView { + @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.statusView(self, contentWarningActionButtonPressed: sender) } + + @objc private func pollVoteButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, pollVoteButtonPressed: sender) + } + } // MARK: - AvatarConfigurableView diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 64ffb167b..13c3afba4 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,15 +13,16 @@ import CoreData import CoreDataStack protocol StatusTableViewCellDelegate: class { - var context: AppContext! { get} + var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) - } final class StatusTableViewCell: UITableViewCell { @@ -101,6 +102,7 @@ extension StatusTableViewCell { // MARK: - UITableViewDelegate extension StatusTableViewCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { if tableView === statusView.pollTableView, let diffableDataSource = statusView.pollTableViewDataSource { var pollID: String? @@ -115,6 +117,7 @@ extension StatusTableViewCell: UITableViewDelegate { pollID = option.poll.id return !option.poll.expired } else { + assertionFailure() return true } } @@ -143,20 +146,31 @@ extension StatusTableViewCell: UITableViewDelegate { (option.votedBy ?? Set()).map { $0.id }.contains(userID) } let didVotedLocal = !votedOptions.isEmpty - guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { - return nil + + if poll.multiple { + guard !option.poll.expired, !didVotedRemote else { + return nil + } + } else { + guard !option.poll.expired, !didVotedRemote, !didVotedLocal else { + return nil + } } return indexPath } else { + assertionFailure() return indexPath } } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if tableView === statusView.pollTableView { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) delegate?.statusTableViewCell(self, pollTableView: statusView.pollTableView, didSelectRowAt: indexPath) + } else { + assertionFailure() } } @@ -164,9 +178,15 @@ extension StatusTableViewCell: UITableViewDelegate { // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+Poll.swift b/Mastodon/Service/APIService/APIService+Poll.swift index 350da97c8..0b240466a 100644 --- a/Mastodon/Service/APIService/APIService+Poll.swift +++ b/Mastodon/Service/APIService/APIService+Poll.swift @@ -101,11 +101,13 @@ extension APIService { let votedOptions = poll.options.filter { option in (option.votedBy ?? Set()).map { $0.id }.contains(mastodonUser.id) } - guard votedOptions.isEmpty else { - // if did voted. Do not allow vote again + + if !poll.multiple, !votedOptions.isEmpty { + // if did voted for single poll. Do not allow vote again didVotedLocal = true return } + for option in options { let voted = choices.contains(option.index.intValue) option.update(voted: voted, by: mastodonUser) From 07d3c3cbff475cd27b2b0c98454a8ff436de46ee Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 15:56:20 +0800 Subject: [PATCH 040/400] chore: comment out animation logging --- Mastodon/Scene/Share/View/Control/StripProgressView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Share/View/Control/StripProgressView.swift b/Mastodon/Scene/Share/View/Control/StripProgressView.swift index e6550ba3d..710d8567d 100644 --- a/Mastodon/Scene/Share/View/Control/StripProgressView.swift +++ b/Mastodon/Scene/Share/View/Control/StripProgressView.swift @@ -34,7 +34,7 @@ private final class StripProgressLayer: CALayer { return presentation()?.progress ?? self.progress }() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0) guard let context = UIGraphicsGetCurrentContext() else { From 7ad09468e227a0324cde6a8db35069b14787d4da Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 19:30:17 +0800 Subject: [PATCH 041/400] fix: set image missing corner radius --- .../PickServer/TableViewCell/PickServerCell.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 6e36651fb..52133c4ba 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -7,7 +7,7 @@ import UIKit import MastodonSDK -import Kingfisher +import AlamofireImage import Kanna protocol PickServerCellDelegate: class { @@ -362,9 +362,11 @@ extension PickServerCell { guard let serverInfo = server else { return } thumbnailActivityIdicator.startAnimating() + let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) thumbnailImageView.af.setImage( withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, - placeholderImage: UIImage.placeholder(color: .systemFill), + placeholderImage: placeholderImage, + filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), imageTransition: .crossDissolve(0.33), completion: { [weak self] response in guard let self = self else { return } From 087413009dc75f41e077a3fc51c11f3166be39a7 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 19:30:52 +0800 Subject: [PATCH 042/400] chore: update function signature --- .../Onboarding/Register/MastodonRegisterViewController.swift | 4 ++-- .../Scene/Onboarding/Register/MastodonRegisterViewModel.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index fbb952834..1ce33f475 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -228,7 +228,7 @@ extension MastodonRegisterViewController { domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() - passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: false) + passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty) usernameTextField.rightView = domainLabel usernameTextField.rightViewMode = .always usernameTextField.delegate = self @@ -441,7 +441,7 @@ extension MastodonRegisterViewController { .sink { [weak self] validateState in guard let self = self else { return } self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) - self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(isValid: validateState == .valid) + self.passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: validateState) } .store(in: &disposeBag) viewModel.passwordErrorPrompt diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 8f03cb124..7089aef7c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -180,12 +180,12 @@ extension MastodonRegisterViewModel { return NSAttributedString(attachment: attachment) } - static func attributeStringForPassword(isValid: Bool) -> NSAttributedString { + static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.checkmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: isValid ? .black : .clear)) + attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .black : .clear)) attributeString.append(NSAttributedString(string: " ")) let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]) attributeString.append(eightCharactersDescription) From 652c286c719a0c6c481fbf342e41b2211962da7d Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 20:45:48 +0800 Subject: [PATCH 043/400] fix: password error prompt layout issue --- .../xcschemes/xcschememanagement.plist | 27 +++---------------- .../MastodonRegisterViewController.swift | 9 ++++--- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index bc78dfa4b..747fe7df0 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 7 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,31 +22,10 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 1 + 12 SuppressBuildableAutocreation - - DB427DD125BAA00100D1B89D - - primary - - - DB427DE725BAA00100D1B89D - - primary - - - DB427DF225BAA00100D1B89D - - primary - - - DB89B9F525C10FD0008580ED - - primary - - - + diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 1ce33f475..d1ef11c67 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -220,6 +220,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O } extension MastodonRegisterViewController { + override func viewDidLoad() { super.viewDidLoad() @@ -277,7 +278,7 @@ extension MastodonRegisterViewController { passwordErrorPromptLabel.translatesAutoresizingMaskIntoConstraints = false stackView.addSubview(passwordErrorPromptLabel) NSLayoutConstraint.activate([ - passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordTextField.bottomAnchor, constant: 6), + passwordErrorPromptLabel.topAnchor.constraint(equalTo: passwordCheckLabel.bottomAnchor, constant: 2), passwordErrorPromptLabel.leadingAnchor.constraint(equalTo: passwordTextField.leadingAnchor), passwordErrorPromptLabel.trailingAnchor.constraint(equalTo: passwordTextField.trailingAnchor), ]) @@ -552,11 +553,13 @@ extension MastodonRegisterViewController { avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width/2 - plusIconImageView.clipsToBounds = true + plusIconImageView.layer.cornerRadius = plusIconImageView.frame.width / 2 + plusIconImageView.layer.masksToBounds = true } + } extension MastodonRegisterViewController: UITextFieldDelegate { From 54c7610c7f4fda93b9b9c237f27f8ee057467f94 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 5 Mar 2021 22:50:20 +0800 Subject: [PATCH 044/400] chore: [WIP] refactor pick server scene with diffable data source --- Mastodon.xcodeproj/project.pbxproj | 26 +- .../xcschemes/xcschememanagement.plist | 2 +- .../Diffiable/Item/CategoryPickerItem.swift | 76 ++++++ Mastodon/Diffiable/Item/Item.swift | 3 +- .../Section/CategoryPickerSection.swift | 31 +++ .../Diffiable/Section/PickServerItem.swift | 67 +++++ .../Diffiable/Section/PickServerSection.swift | 100 +++++++ ...PickServerCategoryCollectionViewCell.swift | 6 - .../MastodonPickServerViewController.swift | 243 ++++++++--------- ...MastodonPickServerViewModel+Diffable.swift | 37 +++ ...rverViewModel+LoadIndexedServerState.swift | 84 ++++++ .../MastodonPickServerViewModel.swift | 249 +++++++++--------- .../PickServerCategoriesCell.swift | 74 +++--- .../TableViewCell/PickServerCell.swift | 176 ++++++------- .../TableViewCell/PickServerSearchCell.swift | 12 +- .../View/PickServerCategoryView.swift | 80 +++--- ...PinBasedAuthenticationViewController.swift | 2 - .../APIService/APIService+Onboarding.swift | 2 +- 18 files changed, 830 insertions(+), 440 deletions(-) create mode 100644 Mastodon/Diffiable/Item/CategoryPickerItem.swift create mode 100644 Mastodon/Diffiable/Section/CategoryPickerSection.swift create mode 100644 Mastodon/Diffiable/Section/PickServerItem.swift create mode 100644 Mastodon/Diffiable/Section/PickServerSection.swift create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f66c14c40..cfe6a3ed0 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -96,6 +96,12 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; + DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; + DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */; }; + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; + DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; @@ -310,6 +316,12 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSection.swift; sourceTree = ""; }; + DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PickServerItem.swift; path = Mastodon/Diffiable/Section/PickServerItem.swift; sourceTree = SOURCE_ROOT; }; + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -456,11 +468,13 @@ 0FAA102525E1125D0017CCDE /* PickServer */ = { isa = PBXGroup; children = ( - 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D30D25E525C000AAD544 /* View */, + 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */, 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */, + DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */, + DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */, ); path = PickServer; sourceTree = ""; @@ -642,6 +656,8 @@ isa = PBXGroup; children = ( 2D76319E25C1521200929FB9 /* StatusSection.swift */, + DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, + DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */, ); path = Section; sourceTree = ""; @@ -683,6 +699,8 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + DB1FD44925F26CD7004CFCFC /* PickServerItem.swift */, + DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, ); path = Item; sourceTree = ""; @@ -1464,8 +1482,10 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, + DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, + DB1FD44A25F26CD7004CFCFC /* PickServerItem.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, @@ -1511,12 +1531,14 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+TimelinePostTableViewCellDelegate.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, + DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, @@ -1533,10 +1555,12 @@ DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, + DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 747fe7df0..60ccd3d87 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 12 + 8 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift new file mode 100644 index 000000000..9a8f8bd6c --- /dev/null +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -0,0 +1,76 @@ +// +// CategoryPickerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +enum CategoryPickerItem { + case all + case category(category: Mastodon.Entity.Category) +} + +extension CategoryPickerItem { + var title: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.all + case .category(let category): + switch category.category { + case .academia: + return "📚" + case .activism: + return "✊" + case .food: + return "🍕" + case .furry: + return "🦁" + case .games: + return "🕹" + case .general: + return "💬" + case .journalism: + return "📰" + case .lgbt: + return "🏳️‍🌈" + case .regional: + return "📍" + case .art: + return "🎨" + case .music: + return "🎼" + case .tech: + return "📱" + case ._other: + return "❓" + } + } + } +} + +extension CategoryPickerItem: Equatable { + static func == (lhs: CategoryPickerItem, rhs: CategoryPickerItem) -> Bool { + switch (lhs, rhs) { + case (.all, .all): + return true + case (.category(let categoryLeft), .category(let categoryRight)): + return categoryLeft.category.rawValue == categoryRight.category.rawValue + default: + return false + } + } +} + +extension CategoryPickerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .all: + hasher.combine(String(describing: CategoryPickerItem.all.self)) + case .category(let category): + hasher.combine(category.category.rawValue) + } + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index c6a182b4d..818c33ea8 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -30,7 +30,7 @@ protocol StatusContentWarningAttribute { } extension Item { - class StatusTimelineAttribute: Hashable, StatusContentWarningAttribute { + class StatusTimelineAttribute: Equatable, Hashable, StatusContentWarningAttribute { var isStatusTextSensitive: Bool var isStatusSensitive: Bool @@ -51,7 +51,6 @@ extension Item { hasher.combine(isStatusTextSensitive) hasher.combine(isStatusSensitive) } - } } diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift new file mode 100644 index 000000000..5582cb531 --- /dev/null +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -0,0 +1,31 @@ +// +// CategoryPickerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +enum CategoryPickerSection: Equatable, Hashable { + case main +} + +extension CategoryPickerSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell + switch item { + case .all: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 17) + case .category: + cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) + } + cell.categoryView.titleLabel.text = item.title + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/PickServerItem.swift b/Mastodon/Diffiable/Section/PickServerItem.swift new file mode 100644 index 000000000..09ca72c32 --- /dev/null +++ b/Mastodon/Diffiable/Section/PickServerItem.swift @@ -0,0 +1,67 @@ +// +// PickServerItem.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import Foundation +import MastodonSDK + +/// Note: update Equatable when change case +enum PickServerItem { + case header + case categoryPicker(items: [CategoryPickerItem]) + case search + case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) +} + +extension PickServerItem { + final class ServerItemAttribute: Equatable, Hashable { + var isExpand: Bool + + init(isExpand: Bool) { + self.isExpand = isExpand + } + + static func == (lhs: PickServerItem.ServerItemAttribute, rhs: PickServerItem.ServerItemAttribute) -> Bool { + return lhs.isExpand == rhs.isExpand + } + + func hash(into hasher: inout Hasher) { + hasher.combine(isExpand) + } + } +} + +extension PickServerItem: Equatable { + static func == (lhs: PickServerItem, rhs: PickServerItem) -> Bool { + switch (lhs, rhs) { + case (.header, .header): + return true + case (.categoryPicker(let itemsLeft), .categoryPicker(let itemsRight)): + return itemsLeft == itemsRight + case (.search, .search): + return true + case (.server(let serverLeft, _), .server(let serverRight, _)): + return serverLeft.domain == serverRight.domain + default: + return false + } + } +} + +extension PickServerItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .header: + hasher.combine(String(describing: PickServerItem.header.self)) + case .categoryPicker(let items): + hasher.combine(items) + case .search: + hasher.combine(String(describing: PickServerItem.search.self)) + case .server(let server, _): + hasher.combine(server.domain) + } + } +} diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift new file mode 100644 index 000000000..d76cf4c66 --- /dev/null +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -0,0 +1,100 @@ +// +// PickServerSection.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit +import MastodonSDK +import Kanna + +enum PickServerSection: Equatable, Hashable { + case header + case category + case search + case servers +} + +extension PickServerSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + switch item { + case .header: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell + return cell + case .categoryPicker(let items): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell + cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( + for: cell.collectionView, + dependency: dependency + ) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(items, toSection: .main) + cell.diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + return cell + case .search: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell + cell.delegate = pickServerSearchCellDelegate + return cell + case .server(let server, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell + PickServerSection.configure(cell: cell, server: server, attribute: attribute) + cell.delegate = pickServerCellDelegate + // cell.server = server + // if expandServerDomainSet.contains(server.domain) { + // cell.mode = .expand + // } else { + // cell.mode = .collapse + // } +// if server == viewModel.selectedServer.value { +// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) +// } else { +// tableView.deselectRow(at: indexPath, animated: false) +// } +// +// cell.delegate = self + return cell + } + } + } +} + +extension PickServerSection { + + static func configure(cell: PickServerCell, server: Mastodon.Entity.Server, attribute: PickServerItem.ServerItemAttribute) { + cell.domainLabel.text = server.domain + cell.descriptionLabel.text = { + guard let html = try? HTML(html: server.description, encoding: .utf8) else { + return server.description + } + + return html.text ?? server.description + }() + cell.langValueLabel.text = server.language.uppercased() + cell.usersValueLabel.text = parseUsersCount(server.totalUsers) + cell.categoryValueLabel.text = server.category.uppercased() + + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) +// UIView.animate(withDuration: 0.33) { +// cell.expandBox.layoutIfNeeded() +// } + } + + private static func parseUsersCount(_ usersCount: Int) -> String { + switch usersCount { + case 0..<1000: + return "\(usersCount)" + default: + let usersCountInThousand = Float(usersCount) / 1000.0 + return String(format: "%.1fK", usersCountInThousand) + } + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 1f02baad6..5008ad3a3 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -9,12 +9,6 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { - var category: MastodonPickServerViewModel.Category? { - didSet { - categoryView.category = category - } - } - var categoryView: PickServerCategoryView = { let view = PickServerCategoryView() view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 909d6ec7b..06e75c0cd 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -5,10 +5,9 @@ // Created by BradGao on 2021/2/20. // +import os.log import UIKit import Combine -import OSLog -import MastodonSDK final class MastodonPickServerViewController: UIViewController, NeedsDependency { @@ -22,13 +21,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency private var isAuthenticating = CurrentValueSubject(false) private var expandServerDomainSet = Set() - - enum Section: CaseIterable { - case title - case categories - case search - case serverList - } let tableView: UITableView = { let tableView = ControlContainableTableView() @@ -95,31 +87,16 @@ extension MastodonPickServerViewController { nextStepButton.addTarget(self, action: #selector(nextStepButtonDidClicked(_:)), for: .touchUpInside) tableView.delegate = self - tableView.dataSource = self - - viewModel - .searchedServers - .receive(on: DispatchQueue.main) - .sink { _ in - - } receiveValue: { [weak self] servers in - self?.tableView.beginUpdates() - self?.tableView.reloadSections(IndexSet(integer: 3), with: .automatic) - self?.tableView.endUpdates() - if let selectedServer = self?.viewModel.selectedServer.value, servers.contains(selectedServer) { - // Previously selected server is still in the list, do nothing - } else { - // Previously selected server is not in the updated list, reset the selectedServer's value - self?.viewModel.selectedServer.send(nil) - } - } - .store(in: &disposeBag) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + pickServerSearchCellDelegate: self, + pickServerCellDelegate: self + ) viewModel .selectedServer - .map { - $0 != nil - } + .map { $0 != nil } .assign(to: \.isEnabled, on: nextStepButton) .store(in: &disposeBag) @@ -165,8 +142,6 @@ extension MastodonPickServerViewController { isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() } .store(in: &disposeBag) - - viewModel.fetchAllServers() } @objc @@ -292,142 +267,150 @@ extension MastodonPickServerViewController { } extension MastodonPickServerViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return UIView() + } + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - let category = Section.allCases[section] - switch category { - case .title: + guard let diffableDataSource = viewModel.diffableDataSource else { return 0 } + let sections = diffableDataSource.snapshot().sectionIdentifiers + let section = sections[section] + switch section { + case .header: return 20 - case .categories: + case .category: // Since category view has a blur shadow effect, its height need to be large than the actual height, // Thus we reduce the section header's height by 10, and make the category cell height 60+20(10 inset for top and bottom) return 10 case .search: // Same reason as above return 10 - case .serverList: + case .servers: return 0 } } func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + guard case let .server(server) = item else { return nil } + if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) return nil } + return indexPath } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(server, _) = item else { return } tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - viewModel.selectedServer.send(viewModel.searchedServers.value[indexPath.row]) + viewModel.selectedServer.send(server) } - + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) viewModel.selectedServer.send(nil) } + } -extension MastodonPickServerViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() - } - - func numberOfSections(in tableView: UITableView) -> Int { - return Self.Section.allCases.count - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - let section = Self.Section.allCases[section] - switch section { - case .title, - .categories, - .search: - return 1 - case .serverList: - return viewModel.searchedServers.value.count - } - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - let section = Self.Section.allCases[indexPath.section] - switch section { - case .title: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell - return cell - case .categories: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell - cell.dataSource = self - cell.delegate = self - return cell - case .search: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerSearchCell.self), for: indexPath) as! PickServerSearchCell - cell.delegate = self - return cell - case .serverList: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell - let server = viewModel.searchedServers.value[indexPath.row] - cell.server = server - if expandServerDomainSet.contains(server.domain) { - cell.mode = .expand - } else { - cell.mode = .collapse - } - if server == viewModel.selectedServer.value { - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) - } else { - tableView.deselectRow(at: indexPath, animated: false) - } - - cell.delegate = self - return cell - } - } -} +//extension MastodonPickServerViewController: UITableViewDataSource { -extension MastodonPickServerViewController: PickServerCellDelegate { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { - if newMode == .collapse { - expandServerDomainSet.remove(server.domain) - } else { - expandServerDomainSet.insert(server.domain) - } - - tableView.beginUpdates() - updates() - tableView.endUpdates() - - if newMode == .expand, let modeChangeIndex = self.viewModel.searchedServers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { - self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) - } - } -} +// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// +// let section = Self.Section.allCases[indexPath.section] +// switch section { +// case .title: +// +// case .categories: +// +// case .search: +// +// case .serverList: +// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell +// let server = viewModel.servers.value[indexPath.row] +// // cell.server = server +//// if expandServerDomainSet.contains(server.domain) { +//// cell.mode = .expand +//// } else { +//// cell.mode = .collapse +//// } +// if server == viewModel.selectedServer.value { +// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) +// } else { +// tableView.deselectRow(at: indexPath, animated: false) +// } +// +// cell.delegate = self +// return cell +// } +// } +//} +// MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { - func pickServerSearchCell(didChange searchText: String?) { + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { viewModel.searchText.send(searchText) } } -extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesDelegate { - func numberOfCategories() -> Int { - return viewModel.categories.count - } - - func category(at index: Int) -> MastodonPickServerViewModel.Category { - return viewModel.categories[index] - } - - func selectedIndex() -> Int { - return viewModel.selectCategoryIndex.value - } - - func pickServerCategoriesCell(didSelect index: Int) { - return viewModel.selectCategoryIndex.send(index) +// MARK: - PickServerCellDelegate +extension MastodonPickServerViewController: PickServerCellDelegate { + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .server(_, attribute) = item else { return } + + attribute.isExpand.toggle() + tableView.beginUpdates() + cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) + tableView.endUpdates() + + // expand attribute change do not needs apply snapshot to diffable data source + // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? } + +// func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { +// if newMode == .collapse { +// expandServerDomainSet.remove(server.domain) +// } else { +// expandServerDomainSet.insert(server.domain) +// } +// +// tableView.beginUpdates() +// updates() +// tableView.endUpdates() +// +// if newMode == .expand, let modeChangeIndex = self.viewModel.servers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { +// self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) +// } +// } } +//extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesCellDelegate { +// func numberOfCategories() -> Int { +// return viewModel.categories.count +// } +// +// func category(at index: Int) -> MastodonPickServerViewModel.Category { +// return viewModel.categories[index] +// } +// +// func selectedIndex() -> Int { +// return viewModel.selectCategoryIndex.value +// } +// +// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, didSelect index: Int) { +// return viewModel.selectCategoryIndex.send(index) +// } +//} + // MARK: - OnboardingViewControllerAppearance extension MastodonPickServerViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift new file mode 100644 index 000000000..506cbbc48 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// MastodonPickServerViewController+Diffable.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import UIKit + +extension MastodonPickServerViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + pickServerSearchCellDelegate: PickServerSearchCellDelegate, + pickServerCellDelegate: PickServerCellDelegate + ) { + diffableDataSource = PickServerSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + pickServerSearchCellDelegate: pickServerSearchCellDelegate, + pickServerCellDelegate: pickServerCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + loadIndexedServerStateMachine.enter(LoadIndexedServerState.Loading.self) + } + +} + + diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift new file mode 100644 index 000000000..172973b5c --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -0,0 +1,84 @@ +// +// MastodonPickServerViewModel+LoadIndexedServerState.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/5. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension MastodonPickServerViewModel { + class LoadIndexedServerState: GKState { + weak var viewModel: MastodonPickServerViewModel? + + init(viewModel: MastodonPickServerViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension MastodonPickServerViewModel.LoadIndexedServerState { + + class Initial: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.context.apiService.servers(language: nil, category: nil) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let _ = self else { return } + stateMachine.enter(Idle.self) + viewModel.indexedServers.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + stateMachine.enter(Loading.self) + } + } + } + + class Idle: MastodonPickServerViewModel.LoadIndexedServerState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index a3b2a8768..2e764f9b1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -5,9 +5,10 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit -import OSLog import Combine +import GameplayKit import MastodonSDK import CoreDataStack @@ -17,69 +18,41 @@ class MastodonPickServerViewModel: NSObject { case signIn } - enum Category { - // `all` means search for all categories - case all - // `some` means search for specific category - case some(Mastodon.Entity.Category) - - var title: String { - switch self { - case .all: - return L10n.Scene.ServerPicker.Button.Category.all - case .some(let masCategory): - // TODO: Use emoji as placeholders - switch masCategory.category { - case .academia: - return "📚" - case .activism: - return "✊" - case .food: - return "🍕" - case .furry: - return "🦁" - case .games: - return "🕹" - case .general: - return "GE" - case .journalism: - return "📰" - case .lgbt: - return "🏳️‍🌈" - case .regional: - return "📍" - case .art: - return "🎨" - case .music: - return "🎼" - case .tech: - return "📱" - case ._other: - return "❓" - } - } - } - } - + var disposeBag = Set() + + // input let mode: PickServerMode let context: AppContext - - var categories = [Category]() + var categoryPickerItems: [CategoryPickerItem] = { + var items: [CategoryPickerItem] = [] + items.append(.all) + items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) + return items + }() let selectCategoryIndex = CurrentValueSubject(0) - let searchText = CurrentValueSubject(nil) + let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) - let allServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let searchedServers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadIndexedServerStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadIndexedServerState.Initial(viewModel: self), + LoadIndexedServerState.Loading(viewModel: self), + LoadIndexedServerState.Fail(viewModel: self), + LoadIndexedServerState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadIndexedServerState.Initial.self) + return stateMachine + }() + let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() - - private var disposeBag = Set() - - weak var tableView: UITableView? - + var mastodonPinBasedAuthenticationViewController: UIViewController? init(context: AppContext, mode: PickServerMode) { @@ -91,83 +64,115 @@ class MastodonPickServerViewModel: NSObject { } private func configure() { - let masCategories = context.apiService.stubCategories() - categories.append(.all) - categories.append(contentsOf: masCategories.map { Category.some($0) }) - Publishers.CombineLatest3( - selectCategoryIndex, - searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), - allServers + indexedServers, + unindexedServers, + searchText ) - .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in - guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] indexedServers, unindexedServers, searchText in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } - // 1. Search from the servers recorded in joinmastodon.org - let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) - if !searchedServersFromAPI.isEmpty { - // If found servers, just return - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain - if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { - return self.context.apiService.instance(domain: toSearchText) - .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } - .catch({ error -> Just> in - return Just(Result.failure(error)) - }) - .eraseToAnyPublisher() - } - return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() - } - .sink { _ in - - } receiveValue: { [weak self] result in - switch result { - case .success(let servers): - self?.searchedServers.send(servers) - case .failure(let error): - // TODO: What should be presented when user inputs invalid search text? - self?.searchedServers.send([]) + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotServerItemAttributeDict: [String : PickServerItem.ServerItemAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .server(server, attribute) = item else { continue } + oldSnapshotServerItemAttributeDict[server.domain] = attribute } - } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.header, .category, .search, .servers]) + snapshot.appendItems([.header], toSection: .header) + snapshot.appendItems([.categoryPicker(items: self.categoryPickerItems)], toSection: .category) + snapshot.appendItems([.search], toSection: .search) + // TODO: handle filter + var serverItems: [PickServerItem] = [] + for server in indexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isExpand: false) + let item = PickServerItem.server(server: server, attribute: attribute) + serverItems.append(item) + } + snapshot.appendItems(serverItems, toSection: .servers) + + diffableDataSource.apply(snapshot) + }) .store(in: &disposeBag) + + +// Publishers.CombineLatest3( +// selectCategoryIndex, +// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), +// indexedServers +// ) +// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in +// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } +// +// // 1. Search from the servers recorded in joinmastodon.org +// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) +// if !searchedServersFromAPI.isEmpty { +// // If found servers, just return +// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() +// } +// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain +// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { +// return self.context.apiService.instance(domain: toSearchText) +// .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } +// .catch({ error -> Just> in +// return Just(Result.failure(error)) +// }) +// .eraseToAnyPublisher() +// } +// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() +// } +// .sink { _ in +// +// } receiveValue: { [weak self] result in +// switch result { +// case .success(let servers): +// self?.servers.send(servers) +// case .failure(let error): +// // TODO: What should be presented when user inputs invalid search text? +// self?.servers.send([]) +// } +// +// } +// .store(in: &disposeBag) } - func fetchAllServers() { - context.apiService.servers(language: nil, category: nil) - .sink { completion in - // TODO: Add a reload button when fails to fetch servers initially - } receiveValue: { [weak self] result in - self?.allServers.send(result.value) - } - .store(in: &disposeBag) - - } - - private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { - return allServers - // 1. Filter the category - .filter { - switch category { - case .all: - return true - case .some(let masCategory): - return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame - } - } - // 2. Filter the searchText - .filter { - if let searchText = searchText, !searchText.isEmpty { - return $0.domain.lowercased().contains(searchText.lowercased()) - } else { - return true - } - } - } +// func fetchAllServers() { +// context.apiService.servers(language: nil, category: nil) +// .sink { completion in +// // TODO: Add a reload button when fails to fetch servers initially +// } receiveValue: { [weak self] result in +// self?.indexedServers.send(result.value) +// } +// .store(in: &disposeBag) +// +// } +// +// private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { +// return allServers +// // 1. Filter the category +// .filter { +// switch category { +// case .all: +// return true +// case .some(let masCategory): +// return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame +// } +// } +// // 2. Filter the searchText +// .filter { +// if let searchText = searchText, !searchText.isEmpty { +// return $0.domain.lowercased().contains(searchText.lowercased()) +// } else { +// return true +// } +// } +// } } // MARK: - SignIn methods & structs diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 8f66e9847..1fd366555 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -5,24 +5,20 @@ // Created by BradGao on 2021/2/23. // +import os.log import UIKit import MastodonSDK -protocol PickServerCategoriesDataSource: class { - func numberOfCategories() -> Int - func category(at index: Int) -> MastodonPickServerViewModel.Category - func selectedIndex() -> Int -} - -protocol PickServerCategoriesDelegate: class { - func pickServerCategoriesCell(didSelect index: Int) +protocol PickServerCategoriesCellDelegate: class { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) } final class PickServerCategoriesCell: UITableViewCell { - weak var dataSource: PickServerCategoriesDataSource! - weak var delegate: PickServerCategoriesDelegate! + weak var delegate: PickServerCategoriesCellDelegate? + var diffableDataSource: UICollectionViewDiffableDataSource? + let metricView = UIView() let collectionView: UICollectionView = { @@ -38,6 +34,12 @@ final class PickServerCategoriesCell: UITableViewCell { return view }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -75,7 +77,6 @@ extension PickServerCategoriesCell { ]) collectionView.delegate = self - collectionView.dataSource = self } override func layoutSubviews() { @@ -86,45 +87,46 @@ extension PickServerCategoriesCell { } +// MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) - delegate.pickServerCategoriesCell(didSelect: indexPath.row) +// delegate.pickServerCategoriesCell(self, didSelect: indexPath.row) } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { layoutIfNeeded() return UIEdgeInsets(top: 0, left: metricView.frame.minX - collectionView.frame.minX, bottom: 0, right: collectionView.frame.maxX - metricView.frame.maxX) } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { return 16 } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: 60, height: 80) } } -extension PickServerCategoriesCell: UICollectionViewDataSource { - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return dataSource.numberOfCategories() - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let category = dataSource.category(at: indexPath.row) - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell - cell.category = category - - // Select the default category by default - if indexPath.row == dataSource.selectedIndex() { - // Use `[]` as the scrollPosition to avoid contentOffset change - collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) - cell.isSelected = true - } - return cell - } - - -} +//extension PickServerCategoriesCell: UICollectionViewDataSource { +// func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { +// return dataSource.numberOfCategories() +// } +// +// func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { +// let category = dataSource.category(at: indexPath.row) +// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell +// cell.category = category +// +// // Select the default category by default +// if indexPath.row == dataSource.selectedIndex() { +// // Use `[]` as the scrollPosition to avoid contentOffset change +// collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) +// cell.isSelected = true +// } +// return cell +// } +// +// +//} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 52133c4ba..cadfc74ba 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -5,25 +5,21 @@ // Created by BradGao on 2021/2/24. // +import os.log import UIKit import MastodonSDK import AlamofireImage import Kanna protocol PickServerCellDelegate: class { - func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) + func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) } class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? - enum Mode { - case collapse - case expand - } - - private var containerView: UIView = { + let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) view.backgroundColor = Asset.Colors.lightWhite.color @@ -31,7 +27,7 @@ class PickServerCell: UITableViewCell { return view }() - private var domainLabel: UILabel = { + let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) label.textColor = Asset.Colors.lightDarkGray.color @@ -40,7 +36,7 @@ class PickServerCell: UITableViewCell { return label }() - private var checkbox: UIImageView = { + let checkbox: UIImageView = { let imageView = UIImageView() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) imageView.tintColor = Asset.Colors.lightSecondaryText.color @@ -49,7 +45,7 @@ class PickServerCell: UITableViewCell { return imageView }() - private var descriptionLabel: UILabel = { + let descriptionLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .subheadline) label.numberOfLines = 0 @@ -59,9 +55,9 @@ class PickServerCell: UITableViewCell { return label }() - private let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) + let thumbnailActivityIdicator = UIActivityIndicatorView(style: .medium) - private var thumbnailImageView: UIImageView = { + let thumbnailImageView: UIImageView = { let imageView = UIImageView() imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFill @@ -69,7 +65,7 @@ class PickServerCell: UITableViewCell { return imageView }() - private var infoStackView: UIStackView = { + let infoStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal stackView.alignment = .fill @@ -78,14 +74,14 @@ class PickServerCell: UITableViewCell { return stackView }() - private var expandBox: UIView = { + let expandBox: UIView = { let view = UIView() view.backgroundColor = .clear view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var expandButton: UIButton = { + let expandButton: UIButton = { let button = UIButton(type: .custom) button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected) @@ -95,14 +91,14 @@ class PickServerCell: UITableViewCell { return button }() - private var seperator: UIView = { + let seperator: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.lightBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() - private var langValueLabel: UILabel = { + let langValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -112,7 +108,7 @@ class PickServerCell: UITableViewCell { return label }() - private var usersValueLabel: UILabel = { + let usersValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -122,7 +118,7 @@ class PickServerCell: UITableViewCell { return label }() - private var categoryValueLabel: UILabel = { + let categoryValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) @@ -132,7 +128,7 @@ class PickServerCell: UITableViewCell { return label }() - private var langTitleLabel: UILabel = { + let langTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -143,7 +139,7 @@ class PickServerCell: UITableViewCell { return label }() - private var usersTitleLabel: UILabel = { + let usersTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -154,7 +150,7 @@ class PickServerCell: UITableViewCell { return label }() - private var categoryTitleLabel: UILabel = { + let categoryTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.lightDarkGray.color label.font = .preferredFont(forTextStyle: .caption2) @@ -168,22 +164,12 @@ class PickServerCell: UITableViewCell { private var collapseConstraints: [NSLayoutConstraint] = [] private var expandConstraints: [NSLayoutConstraint] = [] - var mode: PickServerCell.Mode = .collapse { - didSet { - updateMode() - } - } - - var server: Mastodon.Entity.Server? { - didSet { - updateServerInfo() - } - } - override func prepareForReuse() { super.prepareForReuse() + thumbnailImageView.isHidden = false thumbnailImageView.af.cancelImageRequest() + thumbnailActivityIdicator.stopAnimating() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -195,6 +181,7 @@ class PickServerCell: UITableViewCell { super.init(coder: coder) _init() } + } // MARK: - Methods to configure appearance @@ -224,7 +211,7 @@ extension PickServerCell { infoStackView.addArrangedSubview(verticalInfoStackViewUsers) infoStackView.addArrangedSubview(verticalInfoStackViewCategory) - let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required) + let expandButtonTopConstraintInCollapse = expandButton.topAnchor.constraint(equalTo: descriptionLabel.lastBaselineAnchor, constant: 12).priority(.required - 1) collapseConstraints.append(expandButtonTopConstraintInCollapse) let expandButtonTopConstraintInExpand = expandButton.topAnchor.constraint(equalTo: expandBox.bottomAnchor, constant: 8).priority(.defaultHigh) @@ -292,7 +279,7 @@ extension PickServerCell { descriptionLabel.setContentHuggingPriority(.required - 2, for: .vertical) descriptionLabel.setContentCompressionResistancePriority(.required - 2, for: .vertical) - expandButton.addTarget(self, action: #selector(expandButtonDidClicked(_:)), for: .touchUpInside) + expandButton.addTarget(self, action: #selector(expandButtonDidPressed(_:)), for: .touchUpInside) } private func makeVerticalInfoStackView(arrangedView: UIView...) -> UIStackView { @@ -305,8 +292,31 @@ extension PickServerCell { arrangedView.forEach { stackView.addArrangedSubview($0) } return stackView } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + if selected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + } - private func updateMode() { + @objc + private func expandButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.pickServerCell(self, expandButtonPressed: sender) + } +} + +extension PickServerCell { + + enum ExpandMode { + case collapse + case expand + } + + func updateExpandMode(mode: ExpandMode) { switch mode { case .collapse: expandBox.isHidden = true @@ -318,73 +328,35 @@ extension PickServerCell { expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) - - updateThumbnail() } } - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - if selected { - checkbox.image = UIImage(systemName: "checkmark.circle.fill") - } else { - checkbox.image = UIImage(systemName: "circle") - } - } +// private func updateThumbnail() { +// guard let serverInfo = server, +// let proxiedThumbnail = serverInfo.proxiedThumbnail, +// let url = URL(string: proxiedThumbnail) else { +// thumbnailImageView.isHidden = true +// thumbnailActivityIdicator.stopAnimating() +// return +// } +// +// thumbnailImageView.isHidden = false +// thumbnailActivityIdicator.startAnimating() +// +// let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) +// thumbnailImageView.af.setImage( +// withURL: url, +// placeholderImage: placeholderImage, +// filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), +// imageTransition: .crossDissolve(0.33), +// completion: { [weak self] response in +// guard let self = self else { return } +// switch response.result { +// case .success, .failure: +// self.thumbnailActivityIdicator.stopAnimating() +// } +// } +// ) +// } - @objc - private func expandButtonDidClicked(_ sender: UIButton) { - let newMode: Mode = mode == .collapse ? .expand : .collapse - delegate?.pickServerCell(modeChange: server!, newMode: newMode, updates: { [weak self] in - self?.mode = newMode - }) - } -} - -// MARK: - Methods to update data -extension PickServerCell { - private func updateServerInfo() { - guard let serverInfo = server else { return } - domainLabel.text = serverInfo.domain - descriptionLabel.text = { - guard let html = try? HTML(html: serverInfo.description, encoding: .utf8) else { - return serverInfo.description - } - - return html.text ?? serverInfo.description - }() - langValueLabel.text = serverInfo.language.uppercased() - usersValueLabel.text = parseUsersCount(serverInfo.totalUsers) - categoryValueLabel.text = serverInfo.category.uppercased() - } - - private func updateThumbnail() { - guard let serverInfo = server else { return } - - thumbnailActivityIdicator.startAnimating() - let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) - thumbnailImageView.af.setImage( - withURL: URL(string: serverInfo.proxiedThumbnail ?? "")!, - placeholderImage: placeholderImage, - filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), - imageTransition: .crossDissolve(0.33), - completion: { [weak self] response in - guard let self = self else { return } - switch response.result { - case .success, .failure: - self.thumbnailActivityIdicator.stopAnimating() - } - } - ) - } - - private func parseUsersCount(_ usersCount: Int) -> String { - switch usersCount { - case 0..<1000: - return "\(usersCount)" - default: - let usersCountInThousand = Float(usersCount) / 1000.0 - return String(format: "%.1fK", usersCountInThousand) - } - } } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 6df8affa2..2de66fa65 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -8,7 +8,7 @@ import UIKit protocol PickServerSearchCellDelegate: class { - func pickServerSearchCell(didChange searchText: String?) + func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) } class PickServerSearchCell: UITableViewCell { @@ -55,6 +55,12 @@ class PickServerSearchCell: UITableViewCell { return textField }() + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -97,7 +103,7 @@ extension PickServerSearchCell { } extension PickServerSearchCell { - @objc func textFieldDidChange(_ textField: UITextField) { - delegate?.pickServerSearchCell(didChange: textField.text) + @objc private func textFieldDidChange(_ textField: UITextField) { + delegate?.pickServerSearchCell(self, searchTextDidChange: textField.text) } } diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 30fcbc1f9..2c9bd240f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -9,14 +9,14 @@ import UIKit import MastodonSDK class PickServerCategoryView: UIView { - var category: MastodonPickServerViewModel.Category? { - didSet { - updateCategory() - } - } +// var category: MastodonPickServerViewModel.Category? { +// didSet { +// updateCategory() +// } +// } var selected: Bool = false { didSet { - updateSelectStatus() +// updateSelectStatus() } } @@ -56,44 +56,56 @@ extension PickServerCategoryView { private func configure() { addSubview(bgView) addSubview(titleLabel) - + bgView.backgroundColor = Asset.Colors.lightWhite.color - + NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), bgView.trailingAnchor.constraint(equalTo: self.trailingAnchor), bgView.topAnchor.constraint(equalTo: self.topAnchor), bgView.bottomAnchor.constraint(equalTo: self.bottomAnchor), - + titleLabel.centerXAnchor.constraint(equalTo: self.centerXAnchor), titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } - - private func updateCategory() { - guard let category = category else { return } - titleLabel.text = category.title - switch category { - case .all: - titleLabel.font = UIFont.systemFont(ofSize: 17) - case .some: - titleLabel.font = UIFont.systemFont(ofSize: 28) - } - } - - private func updateSelectStatus() { - if selected { - bgView.backgroundColor = Asset.Colors.lightBrandBlue.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightWhite.color - } - } else { - bgView.backgroundColor = Asset.Colors.lightWhite.color - bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) - if case .all = category { - titleLabel.textColor = Asset.Colors.lightBrandBlue.color - } + +// private func updateCategory() { +// guard let category = category else { return } +// titleLabel.text = category.title +// switch category { +// case .all: +// titleLabel.font = UIFont.systemFont(ofSize: 17) +// case .some: +// titleLabel.font = UIFont.systemFont(ofSize: 28) +// } +// } +// +// private func updateSelectStatus() { +// if selected { +// bgView.backgroundColor = Asset.Colors.lightBrandBlue.color +// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) +// if case .all = category { +// titleLabel.textColor = Asset.Colors.lightWhite.color +// } +// } else { +// bgView.backgroundColor = Asset.Colors.lightWhite.color +// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) +// if case .all = category { +// titleLabel.textColor = Asset.Colors.lightBrandBlue.color +// } +// } +// } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerCategoryView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview { + PickServerCategoryView() } } } +#endif diff --git a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift index fa57ddfd4..d566da4c3 100644 --- a/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift +++ b/Mastodon/Scene/Onboarding/PinBasedAuthentication/MastodonPinBasedAuthenticationViewController.swift @@ -39,8 +39,6 @@ final class MastodonPinBasedAuthenticationViewController: UIViewController, Need } - - extension MastodonPinBasedAuthenticationViewController { override func viewDidLoad() { diff --git a/Mastodon/Service/APIService/APIService+Onboarding.swift b/Mastodon/Service/APIService/APIService+Onboarding.swift index 450dc141c..5cbf455a0 100644 --- a/Mastodon/Service/APIService/APIService+Onboarding.swift +++ b/Mastodon/Service/APIService/APIService+Onboarding.swift @@ -23,7 +23,7 @@ extension APIService { return Mastodon.API.Onboarding.categories(session: session) } - func stubCategories() -> [Mastodon.Entity.Category] { + static func stubCategories() -> [Mastodon.Entity.Category] { return Mastodon.Entity.Category.Kind.allCases.map { kind in return Mastodon.Entity.Category(category: kind.rawValue, serversCount: 0) } From e70fd532c458a7d3d6d8037d7401ad1ee73bb878 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 12:55:52 +0800 Subject: [PATCH 045/400] feat: [WIP] display empty state when fetching server list --- Localization/app.json | 6 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/PickServerSection.swift | 39 ++++- Mastodon/Generated/Strings.swift | 6 + .../Resources/en.lproj/Localizable.strings | 2 + .../MastodonPickServerViewController.swift | 115 +++++++++++++-- ...rverViewModel+LoadIndexedServerState.swift | 12 +- .../MastodonPickServerViewModel.swift | 25 +++- .../PickServerCategoriesCell.swift | 4 +- .../TableViewCell/PickServerCell.swift | 36 ++--- .../TableViewCell/PickServerSearchCell.swift | 4 +- .../TableViewCell/PickServerTitleCell.swift | 4 +- .../View/PickServerEmptyStateView.swift | 135 ++++++++++++++++++ 13 files changed, 337 insertions(+), 55 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift diff --git a/Localization/app.json b/Localization/app.json index 9c438b568..58807fc2f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -64,6 +64,10 @@ }, "input": { "placeholder": "Find a server or join your own..." + }, + "empty_state": { + "finding_servers": "Finding available servers...", + "bad_network": "Something went wrong while loading data. Check your internet connection." } }, "register": { @@ -150,4 +154,4 @@ "title": "Public" } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cfe6a3ed0..ecec29164 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -156,6 +156,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */; }; DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF55C25C138B7002E6C99 /* UIViewController.swift */; }; DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; + DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -383,6 +384,7 @@ DB8AF54F25C13703002E6C99 /* MainTabBarController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; DB8AF55C25C138B7002E6C99 /* UIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; + DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -494,6 +496,7 @@ isa = PBXGroup; children = ( 0FB3D30E25E525CD00AAD544 /* PickServerCategoryView.swift */, + DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */, ); path = View; sourceTree = ""; @@ -1554,6 +1557,7 @@ 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index d76cf4c66..813ef9b20 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -8,6 +8,7 @@ import UIKit import MastodonSDK import Kanna +import AlamofireImage enum PickServerSection: Equatable, Hashable { case header @@ -82,9 +83,41 @@ extension PickServerSection { cell.categoryValueLabel.text = server.category.uppercased() cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) -// UIView.animate(withDuration: 0.33) { -// cell.expandBox.layoutIfNeeded() -// } + + cell.expandMode + .receive(on: DispatchQueue.main) + .sink { mode in + switch mode { + case .collapse: + // do nothing + break + case .expand: + let placeholderImage = UIImage.placeholder(size: cell.thumbnailImageView.frame.size, color: .systemFill) + .af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: false) + guard let proxiedThumbnail = server.proxiedThumbnail, + let url = URL(string: proxiedThumbnail) else { + cell.thumbnailImageView.image = placeholderImage + cell.thumbnailActivityIdicator.stopAnimating() + return + } + cell.thumbnailImageView.isHidden = false + cell.thumbnailActivityIdicator.startAnimating() + + cell.thumbnailImageView.af.setImage( + withURL: url, + placeholderImage: placeholderImage, + filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: cell.thumbnailImageView.frame.size, radius: 3), + imageTransition: .crossDissolve(0.33), + completion: { [weak cell] response in + switch response.result { + case .success, .failure: + cell?.thumbnailActivityIdicator.stopAnimating() + } + } + ) + } + } + .store(in: &cell.disposeBag) } private static func parseUsersCount(_ usersCount: Int) -> String { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 75a661fad..035c0011a 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -236,6 +236,12 @@ internal enum L10n { internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") } } + internal enum EmptyState { + /// Something went wrong while loading data. Check your internet connection. + internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") + /// Finding available servers... + internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") + } internal enum Input { /// Find a server or join your own... internal static let placeholder = L10n.tr("Localizable", "Scene.ServerPicker.Input.Placeholder") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 0ab1c70e4..4cf8ea52e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -68,6 +68,8 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.Seeless" = "See Less"; "Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 06e75c0cd..d803e0054 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -12,16 +12,19 @@ import Combine final class MastodonPickServerViewController: UIViewController, NeedsDependency { private var disposeBag = Set() + private var tableViewObservation: NSKeyValueObservation? weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var viewModel: MastodonPickServerViewModel! - private var isAuthenticating = CurrentValueSubject(false) - private var expandServerDomainSet = Set() - + + let emptyStateView = PickServerEmptyStateView() + let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling + var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(PickServerTitleCell.self, forCellReuseIdentifier: String(describing: PickServerTitleCell.self)) @@ -45,6 +48,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency }() deinit { + tableViewObservation = nil os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -52,10 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() @@ -70,6 +70,38 @@ extension MastodonPickServerViewController { view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 7) + ]) + + // fix AutoLayout warning when observe before view appear + viewModel.viewWillAppear + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self = self else { return } + self.tableViewObservation = self.tableView.observe(\.contentSize, options: [.initial, .new]) { [weak self] tableView, _ in + guard let self = self else { return } + self.updateEmptyStateViewLayout() + } + } + .store(in: &disposeBag) + + tableViewTopPaddingView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableViewTopPaddingView) + tableViewTopPaddingViewHeightLayoutConstraint = tableViewTopPaddingView.heightAnchor.constraint(equalToConstant: 0.0).priority(.defaultHigh) + NSLayoutConstraint.activate([ + tableViewTopPaddingView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + tableViewTopPaddingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableViewTopPaddingView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableViewTopPaddingViewHeightLayoutConstraint, + ]) + tableViewTopPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.addSubview(tableView) NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), @@ -135,15 +167,50 @@ extension MastodonPickServerViewController { } .store(in: &disposeBag) - isAuthenticating + viewModel.isAuthenticating .receive(on: DispatchQueue.main) .sink { [weak self] isAuthenticating in guard let self = self else { return } isAuthenticating ? self.nextStepButton.showLoading() : self.nextStepButton.stopLoading() } .store(in: &disposeBag) + + viewModel.emptyStateViewState + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .none: + self.emptyStateView.networkIndicatorImageView.isHidden = true + self.emptyStateView.activityIndicatorView.stopAnimating() + self.emptyStateView.infoLabel.isHidden = true + case .loading: + self.emptyStateView.networkIndicatorImageView.isHidden = true + self.emptyStateView.activityIndicatorView.startAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left + case .badNetwork: + self.emptyStateView.networkIndicatorImageView.isHidden = false + self.emptyStateView.activityIndicatorView.stopAnimating() + self.emptyStateView.infoLabel.isHidden = false + self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + self.emptyStateView.infoLabel.textAlignment = .center + } + } + .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.viewWillAppear.send() + } + + +} + +extension MastodonPickServerViewController { + @objc private func nextStepButtonDidClicked(_ sender: UIButton) { switch viewModel.mode { @@ -156,7 +223,7 @@ extension MastodonPickServerViewController { private func doSignIn() { guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.createApplication(domain: server.domain) .tryMap { response -> MastodonPickServerViewModel.AuthenticateInfo in let application = response.value @@ -168,7 +235,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -196,7 +263,7 @@ extension MastodonPickServerViewController { private func doSignUp() { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let server = viewModel.selectedServer.value else { return } - isAuthenticating.send(true) + viewModel.isAuthenticating.send(true) context.apiService.instance(domain: server.domain) .compactMap { [weak self] response -> AnyPublisher? in @@ -232,7 +299,7 @@ extension MastodonPickServerViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } - self.isAuthenticating.send(false) + self.viewModel.isAuthenticating.send(false) switch completion { case .failure(let error): @@ -266,10 +333,23 @@ extension MastodonPickServerViewController { } } +// MARK: - UITableViewDelegate extension MastodonPickServerViewController: UITableViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === tableView else { return } + let offsetY = scrollView.contentOffset.y + scrollView.safeAreaInsets.top + if offsetY < 0 { + tableViewTopPaddingViewHeightLayoutConstraint.constant = abs(offsetY) + } else { + tableViewTopPaddingViewHeightLayoutConstraint.constant = 0 + } + } + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return UIView() + let headerView = UIView() + headerView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + return headerView } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { @@ -320,6 +400,15 @@ extension MastodonPickServerViewController: UITableViewDelegate { } +extension MastodonPickServerViewController { + private func updateEmptyStateViewLayout() { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return } + guard let indexPath = diffableDataSource.indexPath(for: .search) else { return } + let rectInTableView = tableView.rectForRow(at: indexPath) + + emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY + } +} //extension MastodonPickServerViewController: UITableViewDataSource { // func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index 172973b5c..85c6ab289 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -41,6 +41,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { super.didEnter(from: previousState) guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.isLoadingIndexedServers.value = true viewModel.context.apiService.servers(language: nil, category: nil) .sink { completion in switch completion { @@ -67,9 +68,9 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + guard let stateMachine = self.stateMachine else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } + guard let _ = self else { return } stateMachine.enter(Loading.self) } } @@ -79,6 +80,13 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return false } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + viewModel.isLoadingIndexedServers.value = false + } } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 2e764f9b1..8953e2ed9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -13,11 +13,18 @@ import MastodonSDK import CoreDataStack class MastodonPickServerViewModel: NSObject { + enum PickServerMode { case signUp case signIn } + enum EmptyStateViewState { + case none + case loading + case badNetwork + } + var disposeBag = Set() // input @@ -33,6 +40,7 @@ class MastodonPickServerViewModel: NSObject { let searchText = CurrentValueSubject(nil) let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) + let viewWillAppear = PassthroughSubject() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,12 +55,15 @@ class MastodonPickServerViewModel: NSObject { stateMachine.enter(LoadIndexedServerState.Initial.self) return stateMachine }() - let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() let authenticated = PassthroughSubject<(domain: String, account: Mastodon.Entity.Account), Never>() - + let isAuthenticating = CurrentValueSubject(false) + + let isLoadingIndexedServers = CurrentValueSubject(false) + let emptyStateViewState = CurrentValueSubject(.none) + var mastodonPinBasedAuthenticationViewController: UIViewController? init(context: AppContext, mode: PickServerMode) { @@ -99,6 +110,16 @@ class MastodonPickServerViewModel: NSObject { }) .store(in: &disposeBag) + isLoadingIndexedServers + .map { isLoadingIndexedServers -> EmptyStateViewState in + if isLoadingIndexedServers { + return .loading + } else { + return .none + } + } + .assign(to: \.value, on: emptyStateViewState) + .store(in: &disposeBag) // Publishers.CombineLatest3( // selectCategoryIndex, diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 1fd366555..66f8caac1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -54,8 +54,8 @@ final class PickServerCategoriesCell: UITableViewCell { extension PickServerCategoriesCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color metricView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(metricView) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index cadfc74ba..95a5491cc 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import Combine import MastodonSDK import AlamofireImage import Kanna @@ -19,6 +20,10 @@ class PickServerCell: UITableViewCell { weak var delegate: PickServerCellDelegate? + var disposeBag = Set() + + let expandMode = CurrentValueSubject(.collapse) + let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) @@ -170,6 +175,7 @@ class PickServerCell: UITableViewCell { thumbnailImageView.isHidden = false thumbnailImageView.af.cancelImageRequest() thumbnailActivityIdicator.stopAnimating() + disposeBag.removeAll() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -329,34 +335,8 @@ extension PickServerCell { NSLayoutConstraint.activate(expandConstraints) NSLayoutConstraint.deactivate(collapseConstraints) } + + expandMode.value = mode } -// private func updateThumbnail() { -// guard let serverInfo = server, -// let proxiedThumbnail = serverInfo.proxiedThumbnail, -// let url = URL(string: proxiedThumbnail) else { -// thumbnailImageView.isHidden = true -// thumbnailActivityIdicator.stopAnimating() -// return -// } -// -// thumbnailImageView.isHidden = false -// thumbnailActivityIdicator.startAnimating() -// -// let placeholderImage = UIImage.placeholder(color: .systemFill).af.imageRounded(withCornerRadius: 3.0, divideRadiusByImageScale: true) -// thumbnailImageView.af.setImage( -// withURL: url, -// placeholderImage: placeholderImage, -// filter: AspectScaledToFillSizeWithRoundedCornersFilter(size: thumbnailImageView.frame.size, radius: 3), -// imageTransition: .crossDissolve(0.33), -// completion: { [weak self] response in -// guard let self = self else { return } -// switch response.result { -// case .success, .failure: -// self.thumbnailActivityIdicator.stopAnimating() -// } -// } -// ) -// } - } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 2de66fa65..510df3a0b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -74,8 +74,8 @@ class PickServerSearchCell: UITableViewCell { extension PickServerSearchCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color searchTextField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift index 82d155535..30d24ddc0 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerTitleCell.swift @@ -34,8 +34,8 @@ final class PickServerTitleCell: UITableViewCell { extension PickServerTitleCell { private func _init() { - self.selectionStyle = .none - backgroundColor = .clear + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(titleLabel) NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift new file mode 100644 index 000000000..c553b51f6 --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -0,0 +1,135 @@ +// +// PickServerEmptyStateView.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +final class PickServerEmptyStateView: UIView { + + var topPaddingViewTopLayoutConstraint: NSLayoutConstraint! + + let networkIndicatorImageView: UIImageView = { + let imageView = UIImageView() + let configuration = UIImage.SymbolConfiguration(pointSize: 64, weight: .regular) + imageView.image = UIImage(systemName: "wifi.exclamationmark", withConfiguration: configuration) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let infoLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PickServerEmptyStateView { + + private func _init() { + backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + topPaddingViewTopLayoutConstraint = topPaddingView.topAnchor.constraint(equalTo: topAnchor, constant: 0) + NSLayoutConstraint.activate([ + topPaddingViewTopLayoutConstraint, + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(networkIndicatorImageView) + + let infoContainerView = UIView() + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.leadingAnchor.constraint(equalTo: infoContainerView.leadingAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + activityIndicatorView.bottomAnchor.constraint(equalTo: infoContainerView.bottomAnchor), + ]) + infoLabel.translatesAutoresizingMaskIntoConstraints = false + infoContainerView.addSubview(infoLabel) + NSLayoutConstraint.activate([ + infoLabel.leadingAnchor.constraint(equalTo: activityIndicatorView.trailingAnchor, constant: 4), + infoLabel.centerYAnchor.constraint(equalTo: infoContainerView.centerYAnchor), + infoLabel.trailingAnchor.constraint(equalTo: infoContainerView.trailingAnchor), + ]) + containerStackView.addArrangedSubview(infoContainerView) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 2.0), + ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() + } + +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct PickServerEmptyStateView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.badNetwork + emptyStateView.infoLabel.textAlignment = .center + emptyStateView.activityIndicatorView.stopAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + UIViewPreview(width: 375) { + let emptyStateView = PickServerEmptyStateView() + emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers + emptyStateView.infoLabel.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? .right : .left + emptyStateView.activityIndicatorView.startAnimating() + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif From 4ce3f96dae39e2989862c5073b6cbceeab97dab4 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 13:04:30 +0800 Subject: [PATCH 046/400] chore: use setPrimitiveValue --- CoreDataStack/Entity/Application.swift | 2 +- CoreDataStack/Entity/Attachment.swift | 2 +- CoreDataStack/Entity/Emoji.swift | 2 +- CoreDataStack/Entity/History.swift | 2 +- .../Entity/MastodonAuthentication.swift | 8 +++--- CoreDataStack/Entity/Mention.swift | 3 ++- CoreDataStack/Entity/Poll.swift | 2 +- CoreDataStack/Entity/PollOption.swift | 2 +- CoreDataStack/Entity/Tag.swift | 2 +- .../xcschemes/xcschememanagement.plist | 27 +++---------------- 10 files changed, 16 insertions(+), 36 deletions(-) diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift index cfbf48f7e..c9aa22833 100644 --- a/CoreDataStack/Entity/Application.swift +++ b/CoreDataStack/Entity/Application.swift @@ -24,7 +24,7 @@ public final class Application: NSManagedObject { public extension Application { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Application.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index f3071872f..e580014c1 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -36,7 +36,7 @@ public extension Attachment { override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(Attachment.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Emoji.swift index f43dcbf4a..933baab96 100644 --- a/CoreDataStack/Entity/Emoji.swift +++ b/CoreDataStack/Entity/Emoji.swift @@ -26,7 +26,7 @@ public final class Emoji: NSManagedObject { public extension Emoji { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Emoji.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 664933687..552e2a406 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -24,7 +24,7 @@ public final class History: NSManagedObject { public extension History { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(History.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/MastodonAuthentication.swift b/CoreDataStack/Entity/MastodonAuthentication.swift index e58c2e877..0ee0e343b 100644 --- a/CoreDataStack/Entity/MastodonAuthentication.swift +++ b/CoreDataStack/Entity/MastodonAuthentication.swift @@ -36,12 +36,12 @@ extension MastodonAuthentication { public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(MastodonAuthentication.identifier)) let now = Date() - createdAt = now - updatedAt = now - activedAt = now + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(MastodonAuthentication.activedAt)) } @discardableResult diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index caec10d32..e659cf891 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -25,7 +25,8 @@ public final class Mention: NSManagedObject { public extension Mention { override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + + setPrimitiveValue(UUID(), forKey: #keyPath(Mention.identifier)) } @discardableResult diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index cc5e7bbcb..356f2fc2e 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -35,7 +35,7 @@ extension Poll { public override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(Poll.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/PollOption.swift b/CoreDataStack/Entity/PollOption.swift index f9a3ce953..8917a7533 100644 --- a/CoreDataStack/Entity/PollOption.swift +++ b/CoreDataStack/Entity/PollOption.swift @@ -27,7 +27,7 @@ extension PollOption { public override func awakeFromInsert() { super.awakeFromInsert() - createdAt = Date() + setPrimitiveValue(Date(), forKey: #keyPath(PollOption.createdAt)) } @discardableResult diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index 3f5d2bcac..d817c774b 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -26,7 +26,7 @@ public final class Tag: NSManagedObject { extension Tag { public override func awakeFromInsert() { super.awakeFromInsert() - identifier = UUID() + setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) } @discardableResult diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 7f54faa33..747fe7df0 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 7 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,31 +22,10 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 1 + 12 SuppressBuildableAutocreation - - DB427DD125BAA00100D1B89D - - primary - - - DB427DE725BAA00100D1B89D - - primary - - - DB427DF225BAA00100D1B89D - - primary - - - DB89B9F525C10FD0008580ED - - primary - - - + From 29653ca6123c69733e4d2ebf8ea3c5ea20becc4b Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 13:29:45 +0800 Subject: [PATCH 047/400] feat: set corner radius for the last cell layer --- Mastodon.xcodeproj/project.pbxproj | 4 ++++ .../xcschemes/xcschememanagement.plist | 4 ++-- Mastodon/Diffiable/Section/PickServerItem.swift | 4 +++- Mastodon/Diffiable/Section/PickServerSection.swift | 11 +++++++++++ .../PickServer/MastodonPickServerAppearance.swift | 12 ++++++++++++ .../MastodonPickServerViewController.swift | 10 +++++----- .../PickServer/MastodonPickServerViewModel.swift | 6 +++++- .../TableViewCell/PickServerSearchCell.swift | 2 +- .../PickServer/View/PickServerEmptyStateView.swift | 6 ++++++ 9 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ecec29164..115a32edd 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -398,6 +399,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -473,6 +475,7 @@ 0FB3D30D25E525C000AAD544 /* View */, 0FB3D31825E525DE00AAD544 /* CollectionViewCell */, 0FB3D2FC25E4CB4B00AAD544 /* TableViewCell */, + DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */, 0FAA102625E1126A0017CCDE /* MastodonPickServerViewController.swift */, 0FB3D2F625E4C24D00AAD544 /* MastodonPickServerViewModel.swift */, DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */, @@ -1563,6 +1566,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB1FD46025F278AF004CFCFC /* CategoryPickerSection.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 60ccd3d87..d9c64a5ed 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 8 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 8 + 7 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/PickServerItem.swift b/Mastodon/Diffiable/Section/PickServerItem.swift index 09ca72c32..13acefeae 100644 --- a/Mastodon/Diffiable/Section/PickServerItem.swift +++ b/Mastodon/Diffiable/Section/PickServerItem.swift @@ -18,9 +18,11 @@ enum PickServerItem { extension PickServerItem { final class ServerItemAttribute: Equatable, Hashable { + var isLast: Bool var isExpand: Bool - init(isExpand: Bool) { + init(isLast: Bool, isExpand: Bool) { + self.isLast = isLast self.isExpand = isExpand } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 813ef9b20..3ae917983 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -84,6 +84,17 @@ extension PickServerSection { cell.updateExpandMode(mode: attribute.isExpand ? .expand : .collapse) + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + cell.expandMode .receive(on: DispatchQueue.main) .sink { mode in diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift new file mode 100644 index 000000000..c5bc56c0c --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerAppearance.swift @@ -0,0 +1,12 @@ +// +// MastodonPickServerAppearance.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021/3/6. +// + +import UIKit + +enum MastodonPickServerAppearance { + static let tableViewCornerRadius: CGFloat = 10 +} diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index d803e0054..6af592f85 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -76,7 +76,7 @@ extension MastodonPickServerViewController { emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 7) + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) // fix AutoLayout warning when observe before view appear @@ -107,7 +107,7 @@ extension MastodonPickServerViewController { tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7) + nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), ]) switch viewModel.mode { @@ -181,16 +181,16 @@ extension MastodonPickServerViewController { guard let self = self else { return } switch state { case .none: - self.emptyStateView.networkIndicatorImageView.isHidden = true - self.emptyStateView.activityIndicatorView.stopAnimating() - self.emptyStateView.infoLabel.isHidden = true + self.emptyStateView.isHidden = true case .loading: + self.emptyStateView.isHidden = false self.emptyStateView.networkIndicatorImageView.isHidden = true self.emptyStateView.activityIndicatorView.startAnimating() self.emptyStateView.infoLabel.isHidden = false self.emptyStateView.infoLabel.text = L10n.Scene.ServerPicker.EmptyState.findingServers self.emptyStateView.infoLabel.textAlignment = self.traitCollection.layoutDirection == .rightToLeft ? .right : .left case .badNetwork: + self.emptyStateView.isHidden = false self.emptyStateView.networkIndicatorImageView.isHidden = false self.emptyStateView.activityIndicatorView.stopAnimating() self.emptyStateView.infoLabel.isHidden = false diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 8953e2ed9..e86d1c590 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -100,10 +100,14 @@ class MastodonPickServerViewModel: NSObject { // TODO: handle filter var serverItems: [PickServerItem] = [] for server in indexedServers { - let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isExpand: false) + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false let item = PickServerItem.server(server: server, attribute: attribute) serverItems.append(item) } + if case let .server(_, attribute) = serverItems.last { + attribute.isLast = true + } snapshot.appendItems(serverItems, toSection: .servers) diffableDataSource.apply(snapshot) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 510df3a0b..23c93a7da 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -24,7 +24,7 @@ class PickServerSearchCell: UITableViewCell { .layerMaxXMinYCorner ] view.layer.cornerCurve = .continuous - view.layer.cornerRadius = 10 + view.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius return view }() diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift index c553b51f6..af744fa92 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerEmptyStateView.swift @@ -45,6 +45,12 @@ extension PickServerEmptyStateView { private func _init() { backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + layer.cornerCurve = .continuous + layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius let topPaddingView = UIView() topPaddingView.translatesAutoresizingMaskIntoConstraints = false From 8568debab0bc3d1ad1b58c2e1790c28b93158b69 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:21:52 +0800 Subject: [PATCH 048/400] feat: make diffable data source work with search text --- .../MastodonPickServerViewController.swift | 2 +- .../MastodonPickServerViewModel.swift | 166 +++++++++--------- .../Share/AuthenticationViewModel.swift | 36 ++-- .../Response/Mastodon+Response+Content.swift | 13 ++ 4 files changed, 121 insertions(+), 96 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 6af592f85..e894cda34 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -445,7 +445,7 @@ extension MastodonPickServerViewController { // MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) { - viewModel.searchText.send(searchText) + viewModel.searchText.send(searchText ?? "") } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e86d1c590..e136e86ce 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -36,10 +36,10 @@ class MastodonPickServerViewModel: NSObject { items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) return items }() - let selectCategoryIndex = CurrentValueSubject(0) - let searchText = CurrentValueSubject(nil) + let selectCategoryItem = CurrentValueSubject(.all) + let searchText = CurrentValueSubject("") let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let viewWillAppear = PassthroughSubject() // output @@ -55,6 +55,7 @@ class MastodonPickServerViewModel: NSObject { stateMachine.enter(LoadIndexedServerState.Initial.self) return stateMachine }() + let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([]) let selectedServer = CurrentValueSubject(nil) let error = PassthroughSubject() @@ -75,13 +76,12 @@ class MastodonPickServerViewModel: NSObject { } private func configure() { - Publishers.CombineLatest3( - indexedServers, - unindexedServers, - searchText + Publishers.CombineLatest( + filteredIndexedServers.eraseToAnyPublisher(), + unindexedServers.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] indexedServers, unindexedServers, searchText in + .sink(receiveValue: { [weak self] indexedServers, unindexedServers in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } @@ -103,6 +103,14 @@ class MastodonPickServerViewModel: NSObject { let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) attribute.isLast = false let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + for server in unindexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } serverItems.append(item) } if case let .server(_, attribute) = serverItems.last { @@ -110,7 +118,8 @@ class MastodonPickServerViewModel: NSObject { } snapshot.appendItems(serverItems, toSection: .servers) - diffableDataSource.apply(snapshot) + diffableDataSource.defaultRowAnimation = .fade + diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil) }) .store(in: &disposeBag) @@ -125,80 +134,77 @@ class MastodonPickServerViewModel: NSObject { .assign(to: \.value, on: emptyStateViewState) .store(in: &disposeBag) -// Publishers.CombineLatest3( -// selectCategoryIndex, -// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), -// indexedServers -// ) -// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher, Never> in -// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() } -// -// // 1. Search from the servers recorded in joinmastodon.org -// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers) -// if !searchedServersFromAPI.isEmpty { -// // If found servers, just return -// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() -// } -// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain -// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") { -// return self.context.apiService.instance(domain: toSearchText) -// .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) } -// .catch({ error -> Just> in -// return Just(Result.failure(error)) -// }) -// .eraseToAnyPublisher() -// } -// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher() -// } -// .sink { _ in -// -// } receiveValue: { [weak self] result in -// switch result { -// case .success(let servers): -// self?.servers.send(servers) -// case .failure(let error): -// // TODO: What should be presented when user inputs invalid search text? -// self?.servers.send([]) -// } -// -// } -// .store(in: &disposeBag) - + Publishers.CombineLatest3( + indexedServers.eraseToAnyPublisher(), + selectCategoryItem.eraseToAnyPublisher(), + searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() + ) + .map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in + // Filter the indexed servers from joinmastodon.org + switch selectCategoryItem { + case .all: + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText) + case .category(let category): + return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText) + } + } + .assign(to: \.value, on: filteredIndexedServers) + .store(in: &disposeBag) + searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] searchText -> AnyPublisher, Error>, Never>? in + // Check if searchText is a valid mastodon server domain + guard let self = self else { return nil } + guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else { + return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher() + } + return self.context.apiService.instance(domain: domain) + .map { response -> Result, Error>in + let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] } + return Result.success(newResponse) + } + .catch { error in + return Just(Result.failure(error)) + } + .eraseToAnyPublisher() + } + .switchToLatest() + .sink(receiveValue: { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let response): + self.unindexedServers.send(response.value) + case .failure(let error): + // TODO: What should be presented when user inputs invalid search text? + self.unindexedServers.send([]) + } + }) + .store(in: &disposeBag) } - -// func fetchAllServers() { -// context.apiService.servers(language: nil, category: nil) -// .sink { completion in -// // TODO: Add a reload button when fails to fetch servers initially -// } receiveValue: { [weak self] result in -// self?.indexedServers.send(result.value) -// } -// .store(in: &disposeBag) -// -// } -// -// private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] { -// return allServers -// // 1. Filter the category -// .filter { -// switch category { -// case .all: -// return true -// case .some(let masCategory): -// return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame -// } -// } -// // 2. Filter the searchText -// .filter { -// if let searchText = searchText, !searchText.isEmpty { -// return $0.domain.lowercased().contains(searchText.lowercased()) -// } else { -// return true -// } -// } -// } + } + +extension MastodonPickServerViewModel { + private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] { + return servers + // 1. Filter the category + .filter { + guard let category = category else { return true } + return $0.category.caseInsensitiveCompare(category) == .orderedSame + } + // 2. Filter the searchText + .filter { + let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !searchText.isEmpty else { + return true + } + return $0.domain.lowercased().contains(searchText.lowercased()) + } + } +} + // MARK: - SignIn methods & structs extension MastodonPickServerViewModel { diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index cb197dc0a..0bd1bf09b 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -42,21 +42,7 @@ final class AuthenticationViewModel { input .map { input in - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !trimmed.isEmpty else { return nil } - - let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed - guard let url = URL(string: urlString), - let host = url.host else { - return nil - } - let components = host.components(separatedBy: ".") - guard !components.contains(where: { $0.isEmpty }) else { return nil } - guard components.count >= 2 else { return nil } - - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) - - return host + AuthenticationViewModel.parseDomain(from: input) } .assign(to: \.value, on: domain) .store(in: &disposeBag) @@ -77,6 +63,26 @@ final class AuthenticationViewModel { } +extension AuthenticationViewModel { + static func parseDomain(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + + let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed + guard let url = URL(string: urlString), + let host = url.host else { + return nil + } + let components = host.components(separatedBy: ".") + guard !components.contains(where: { $0.isEmpty }) else { return nil } + guard components.count >= 2 else { return nil } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host) + + return host + } +} + extension AuthenticationViewModel { enum AuthenticationError: Error, LocalizedError { case badCredentials diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index f99614311..a74d0fcaa 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -39,10 +39,23 @@ extension Mastodon.Response { }() } + init(value: T, old: Mastodon.Response.Content) { + self.value = value + self.date = old.date + self.rateLimit = old.rateLimit + self.responseTime = old.responseTime + } + } } extension Mastodon.Response.Content { + public func map(_ transform: (T) -> R) -> Mastodon.Response.Content { + return Mastodon.Response.Content(value: transform(value), old: self) + } +} + +extension Mastodon.Response { public struct RateLimit { public let limit: Int From 893dc2a66827cea7b26492afe7662da30aa45ea0 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:46:04 +0800 Subject: [PATCH 049/400] feat: make diffable data source works with category picker --- .../Section/CategoryPickerSection.swift | 16 ++++ .../Diffiable/Section/PickServerSection.swift | 17 +--- ...PickServerCategoryCollectionViewCell.swift | 9 +- .../MastodonPickServerViewController.swift | 96 ++++++------------- ...MastodonPickServerViewModel+Diffable.swift | 2 + .../PickServerCategoriesCell.swift | 28 +----- .../TableViewCell/PickServerSearchCell.swift | 2 +- .../View/PickServerCategoryView.swift | 39 +------- 8 files changed, 64 insertions(+), 145 deletions(-) diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 5582cb531..2164d9ebc 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -25,6 +25,22 @@ extension CategoryPickerSection { cell.categoryView.titleLabel.font = .systemFont(ofSize: 28) } cell.categoryView.titleLabel.text = item.title + cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in + if cell.isSelected { + cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color + } + } else { + cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) + if case .all = item { + cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color + } + } + } + .store(in: &cell.observations) return cell } } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 3ae917983..083e9b813 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -21,16 +21,18 @@ extension PickServerSection { static func tableViewDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .header: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell return cell case .categoryPicker(let items): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCategoriesCell.self), for: indexPath) as! PickServerCategoriesCell + cell.delegate = pickServerCategoriesCellDelegate cell.diffableDataSource = CategoryPickerSection.collectionViewDiffableDataSource( for: cell.collectionView, dependency: dependency @@ -48,19 +50,6 @@ extension PickServerSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell PickServerSection.configure(cell: cell, server: server, attribute: attribute) cell.delegate = pickServerCellDelegate - // cell.server = server - // if expandServerDomainSet.contains(server.domain) { - // cell.mode = .expand - // } else { - // cell.mode = .collapse - // } -// if server == viewModel.selectedServer.value { -// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) -// } else { -// tableView.deselectRow(at: indexPath, animated: false) -// } -// -// cell.delegate = self return cell } } diff --git a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift index 5008ad3a3..9793d40fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/CollectionViewCell/PickServerCategoryCollectionViewCell.swift @@ -9,16 +9,17 @@ import UIKit class PickServerCategoryCollectionViewCell: UICollectionViewCell { + var observations = Set() + var categoryView: PickServerCategoryView = { let view = PickServerCategoryView() view.translatesAutoresizingMaskIntoConstraints = false return view }() - override var isSelected: Bool { - didSet { - categoryView.selected = isSelected - } + override func prepareForReuse() { + super.prepareForReuse() + observations.removeAll() } override init(frame: CGRect) { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index e894cda34..f3e811b9f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -122,6 +122,7 @@ extension MastodonPickServerViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, + pickServerCategoriesCellDelegate: self, pickServerSearchCellDelegate: self, pickServerCellDelegate: self ) @@ -398,6 +399,28 @@ extension MastodonPickServerViewController: UITableViewDelegate { viewModel.selectedServer.send(nil) } + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .categoryPicker: + guard let cell = cell as? PickServerCategoriesCell else { return } + guard let diffableDataSource = cell.diffableDataSource else { return } + let snapshot = diffableDataSource.snapshot() + + let item = viewModel.selectCategoryItem.value + guard let section = snapshot.indexOfSection(.main), + let row = snapshot.indexOfItem(item) else { return } + cell.collectionView.selectItem(at: IndexPath(item: row, section: section), animated: false, scrollPosition: .centeredHorizontally) + case .search: + guard let cell = cell as? PickServerSearchCell else { return } + cell.searchTextField.text = viewModel.searchText.value + default: + break + } + } + } extension MastodonPickServerViewController { @@ -409,38 +432,15 @@ extension MastodonPickServerViewController { emptyStateView.topPaddingViewTopLayoutConstraint.constant = rectInTableView.maxY } } -//extension MastodonPickServerViewController: UITableViewDataSource { -// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { -// -// let section = Self.Section.allCases[indexPath.section] -// switch section { -// case .title: -// -// case .categories: -// -// case .search: -// -// case .serverList: -// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerCell.self), for: indexPath) as! PickServerCell -// let server = viewModel.servers.value[indexPath.row] -// // cell.server = server -//// if expandServerDomainSet.contains(server.domain) { -//// cell.mode = .expand -//// } else { -//// cell.mode = .collapse -//// } -// if server == viewModel.selectedServer.value { -// tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) -// } else { -// tableView.deselectRow(at: indexPath, animated: false) -// } -// -// cell.delegate = self -// return cell -// } -// } -//} +// MARK: - PickServerCategoriesCellDelegate +extension MastodonPickServerViewController: PickServerCategoriesCellDelegate { + func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = cell.diffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + viewModel.selectCategoryItem.value = item ?? .all + } +} // MARK: - PickServerSearchCellDelegate extension MastodonPickServerViewController: PickServerSearchCellDelegate { @@ -465,41 +465,7 @@ extension MastodonPickServerViewController: PickServerCellDelegate { // expand attribute change do not needs apply snapshot to diffable data source // but should I block the viewModel data binding during tableView.beginUpdates/endUpdates? } - -// func pickServerCell(modeChange server: Mastodon.Entity.Server, newMode: PickServerCell.Mode, updates: (() -> Void)) { -// if newMode == .collapse { -// expandServerDomainSet.remove(server.domain) -// } else { -// expandServerDomainSet.insert(server.domain) -// } -// -// tableView.beginUpdates() -// updates() -// tableView.endUpdates() -// -// if newMode == .expand, let modeChangeIndex = self.viewModel.servers.value.firstIndex(where: { $0 == server }), self.tableView.indexPathsForVisibleRows?.last?.row == modeChangeIndex { -// self.tableView.scrollToRow(at: IndexPath(row: modeChangeIndex, section: 3), at: .bottom, animated: true) -// } -// } } -//extension MastodonPickServerViewController: PickServerCategoriesDataSource, PickServerCategoriesCellDelegate { -// func numberOfCategories() -> Int { -// return viewModel.categories.count -// } -// -// func category(at index: Int) -> MastodonPickServerViewModel.Category { -// return viewModel.categories[index] -// } -// -// func selectedIndex() -> Int { -// return viewModel.selectCategoryIndex.value -// } -// -// func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, didSelect index: Int) { -// return viewModel.selectCategoryIndex.send(index) -// } -//} - // MARK: - OnboardingViewControllerAppearance extension MastodonPickServerViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift index 506cbbc48..9da0399e1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+Diffable.swift @@ -12,12 +12,14 @@ extension MastodonPickServerViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, + pickServerCategoriesCellDelegate: PickServerCategoriesCellDelegate, pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) { diffableDataSource = PickServerSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, + pickServerCategoriesCellDelegate: pickServerCategoriesCellDelegate, pickServerSearchCellDelegate: pickServerSearchCellDelegate, pickServerCellDelegate: pickServerCellDelegate ) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 66f8caac1..84ee6017c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -89,9 +89,11 @@ extension PickServerCategoriesCell { // MARK: - UICollectionViewDelegateFlowLayout extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) -// delegate.pickServerCategoriesCell(self, didSelect: indexPath.row) + delegate?.pickServerCategoriesCell(self, collectionView: collectionView, didSelectItemAt: indexPath) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { @@ -106,27 +108,5 @@ extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { return CGSize(width: 60, height: 80) } + } - -//extension PickServerCategoriesCell: UICollectionViewDataSource { -// func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { -// return dataSource.numberOfCategories() -// } -// -// func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { -// let category = dataSource.category(at: indexPath.row) -// let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell -// cell.category = category -// -// // Select the default category by default -// if indexPath.row == dataSource.selectedIndex() { -// // Use `[]` as the scrollPosition to avoid contentOffset change -// collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) -// cell.isSelected = true -// } -// return cell -// } -// -// -//} - diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index 23c93a7da..f35f586a4 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -38,7 +38,7 @@ class PickServerSearchCell: UITableViewCell { return view }() - private var searchTextField: UITextField = { + let searchTextField: UITextField = { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false textField.font = .preferredFont(forTextStyle: .headline) diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 2c9bd240f..7ea147e0a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -9,16 +9,6 @@ import UIKit import MastodonSDK class PickServerCategoryView: UIView { -// var category: MastodonPickServerViewModel.Category? { -// didSet { -// updateCategory() -// } -// } - var selected: Bool = false { - didSet { -// updateSelectStatus() - } - } var bgShadowView: UIView = { let view = UIView() @@ -53,6 +43,7 @@ class PickServerCategoryView: UIView { } extension PickServerCategoryView { + private func configure() { addSubview(bgView) addSubview(titleLabel) @@ -69,33 +60,7 @@ extension PickServerCategoryView { titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), ]) } - -// private func updateCategory() { -// guard let category = category else { return } -// titleLabel.text = category.title -// switch category { -// case .all: -// titleLabel.font = UIFont.systemFont(ofSize: 17) -// case .some: -// titleLabel.font = UIFont.systemFont(ofSize: 28) -// } -// } -// -// private func updateSelectStatus() { -// if selected { -// bgView.backgroundColor = Asset.Colors.lightBrandBlue.color -// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) -// if case .all = category { -// titleLabel.textColor = Asset.Colors.lightWhite.color -// } -// } else { -// bgView.backgroundColor = Asset.Colors.lightWhite.color -// bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) -// if case .all = category { -// titleLabel.textColor = Asset.Colors.lightBrandBlue.color -// } -// } -// } + } #if DEBUG && canImport(SwiftUI) From c6103eed358e8312aca7a8711fecb44b228e9788 Mon Sep 17 00:00:00 2001 From: CMK Date: Sat, 6 Mar 2021 14:59:27 +0800 Subject: [PATCH 050/400] fix: add missing request retry logic --- .../MastodonPickServerViewModel+LoadIndexedServerState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index 85c6ab289..b9cdcc7e4 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -47,7 +47,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { switch completion { case .failure(let error): // TODO: handle error - break + stateMachine.enter(Fail.self) case .finished: break } From 091839c2e4203fdf8dc2cd54a95704ee20782eb6 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 8 Mar 2021 18:17:15 +0800 Subject: [PATCH 051/400] feat: add multipart helper. Add update credentials endpoint --- .../xcschemes/xcschememanagement.plist | 4 +- .../APIService/APIService+Account.swift | 33 +++ .../Mastodon+API+Account+Credentials.swift | 227 ++++++++++++++++++ .../API/Mastodon+API+Account.swift | 192 +-------------- .../MastodonSDK/API/Mastodon+API.swift | 9 +- .../Sources/MastodonSDK/Extension/Data.swift | 37 +++ .../Sources/MastodonSDK/Mastodon.swift | 1 + .../MediaAttachment.swift} | 20 +- .../Query/MultipartFormValue.swift | 37 +++ .../{Protocol => Query}/Query.swift | 20 +- .../API/MastodonSDK+API+AccountTests.swift | 17 +- 11 files changed, 391 insertions(+), 206 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Extension/Data.swift rename MastodonSDK/Sources/MastodonSDK/{Entity/Mastodon+Entity+MediaAttachment.swift => Query/MediaAttachment.swift} (66%) create mode 100644 MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift rename MastodonSDK/Sources/MastodonSDK/{Protocol => Query}/Query.swift (61%) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 60ccd3d87..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 7 + 10 Mastodon - RTL.xcscheme_^#shared#^_ @@ -22,7 +22,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 8 + 7 SuppressBuildableAutocreation diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 6e26dbf83..2218bfa50 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -43,6 +43,39 @@ extension APIService { .eraseToAnyPublisher() } + func accountUpdateCredentials( + domain: String, + query: Mastodon.API.Account.UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.updateCredentials( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + networkDate: response.networkDate, + log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + func accountRegister( domain: String, query: Mastodon.API.Account.RegisterQuery, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift new file mode 100644 index 000000000..04273188b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -0,0 +1,227 @@ +// +// Mastodon+API+Account+Credentials.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func accountsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + } + + /// Register an account + /// + /// Creates a user and account records. + /// + /// - Since: 2.7.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RegisterQuery` with account registration information + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func register( + session: URLSession, + domain: String, + query: RegisterQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: accountsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RegisterQuery: Codable, PostQuery { + public let reason: String? + public let username: String + public let email: String + public let password: String + public let agreement: Bool + public let locale: String + + public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { + self.reason = reason + self.username = username + self.email = email + self.password = password + self.agreement = agreement + self.locale = locale + } + } + +} + +extension Mastodon.API.Account { + + static func verifyCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") + } + + /// Verify account credentials + /// + /// Test to make sure that the user token works. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func verifyCredentials( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: verifyCredentialsEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + static func updateCredentialsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") + } + + /// Update account credentials + /// + /// Update the user's display and preferences. + /// + /// - Since: 1.1.1 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/2/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `CredentialQuery` with update credential information + /// - authorization: user token + /// - Returns: `AnyPublisher` contains updated `Account` nested in the response + public static func updateCredentials( + session: URLSession, + domain: String, + query: UpdateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.patch( + url: updateCredentialsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UpdateCredentialQuery: PatchQuery { + public let discoverable: Bool? + public let bot: Bool? + public let displayName: String? + public let note: String? + public let avatar: Mastodon.Query.MediaAttachment? + public let header: Mastodon.Query.MediaAttachment? + public let locked: Bool? + public let source: Mastodon.Entity.Source? + public let fieldsAttributes: [Mastodon.Entity.Field]? + + enum CodingKeys: String, CodingKey { + case discoverable + case bot + case displayName = "display_name" + case note + + case avatar + case header + case locked + case source + case fieldsAttributes = "fields_attributes" + } + + public init( + discoverable: Bool? = nil, + bot: Bool? = nil, + displayName: String? = nil, + note: String? = nil, + avatar: Mastodon.Query.MediaAttachment? = nil, + header: Mastodon.Query.MediaAttachment? = nil, + locked: Bool? = nil, + source: Mastodon.Entity.Source? = nil, + fieldsAttributes: [Mastodon.Entity.Field]? = nil + ) { + self.discoverable = discoverable + self.bot = bot + self.displayName = displayName + self.note = note + self.avatar = avatar + self.header = header + self.locked = locked + self.source = source + self.fieldsAttributes = fieldsAttributes + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + discoverable.flatMap { data.append(Data.multipart(key: "discoverable", value: $0)) } + bot.flatMap { data.append(Data.multipart(key: "bot", value: $0)) } + displayName.flatMap { data.append(Data.multipart(key: "display_name", value: $0)) } + note.flatMap { data.append(Data.multipart(key: "note", value: $0)) } + avatar.flatMap { data.append(Data.multipart(key: "avatar", value: $0)) } + header.flatMap { data.append(Data.multipart(key: "header", value: $0)) } + locked.flatMap { data.append(Data.multipart(key: "locked", value: $0)) } + if let source = source { + source.privacy.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0.rawValue)) } + source.sensitive.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + source.language.flatMap { data.append(Data.multipart(key: "source[privacy]", value: $0)) } + } + fieldsAttributes.flatMap { fieldsAttributes in + for fieldsAttribute in fieldsAttributes { + data.append(Data.multipart(key: "fields_attributes[name][]", value: fieldsAttribute.name)) + data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) + } + } + data.append(Data.multipartEnd()) + return data + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index d9b2a4448..3bb81dc6b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -8,119 +8,17 @@ import Foundation import Combine +// MARK: - Retrieve information extension Mastodon.API.Account { - - static func verifyCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/verify_credentials") - } - static func accountsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") - } + static func accountsInfoEndpointURL(domain: String, id: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts") + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("accounts") .appendingPathComponent(id) } - static func updateCredentialsEndpointURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/update_credentials") - } - /// Test to make sure that the user token works. + /// Retrieve information /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response - public static func verifyCredentials( - session: URLSession, - domain: String, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.get( - url: verifyCredentialsEndpointURL(domain: domain), - query: nil, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Creates a user and account records. - /// - /// - Since: 2.7.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `RegisterQuery` with account registration information - /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func register( - session: URLSession, - domain: String, - query: RegisterQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.post( - url: accountsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Token.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Update the user's display and preferences. - /// - /// - Since: 1.1.1 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/2/9 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/accounts/) - /// - Parameters: - /// - session: `URLSession` - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `CredentialQuery` with update credential information - /// - authorization: user token - /// - Returns: `AnyPublisher` contains updated `Account` nested in the response - public static func updateCredentials( - session: URLSession, - domain: String, - query: UpdateCredentialQuery, - authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { - let request = Mastodon.API.patch( - url: updateCredentialsEndpointURL(domain: domain), - query: query, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - /// View information about a profile. /// /// - Since: 0.0.0 @@ -138,11 +36,11 @@ extension Mastodon.API.Account { public static func accountInfo( session: URLSession, domain: String, - query: AccountInfoQuery, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: accountsInfoEndpointURL(domain: domain, id: query.id), + url: accountsInfoEndpointURL(domain: domain, id: userID), query: nil, authorization: authorization ) @@ -155,79 +53,3 @@ extension Mastodon.API.Account { } } - -extension Mastodon.API.Account { - - public struct RegisterQuery: Codable, PostQuery { - public let reason: String? - public let username: String - public let email: String - public let password: String - public let agreement: Bool - public let locale: String - - public init(reason: String? = nil, username: String, email: String, password: String, agreement: Bool, locale: String) { - self.reason = reason - self.username = username - self.email = email - self.password = password - self.agreement = agreement - self.locale = locale - } - } - - public struct UpdateCredentialQuery: Codable, PatchQuery { - - public var discoverable: Bool? - public var bot: Bool? - public var displayName: String? - public var note: String? - public var avatar: String? - public var header: String? - public var locked: Bool? - public var source: Mastodon.Entity.Source? - public var fieldsAttributes: [Mastodon.Entity.Field]? - - enum CodingKeys: String, CodingKey { - case discoverable - case bot - case displayName = "display_name" - case note - - case avatar - case header - case locked - case source - case fieldsAttributes = "fields_attributes" - } - - public init( - discoverable: Bool? = nil, - bot: Bool? = nil, - displayName: String? = nil, - note: String? = nil, - avatar: Mastodon.Entity.MediaAttachment? = nil, - header: Mastodon.Entity.MediaAttachment? = nil, - locked: Bool? = nil, - source: Mastodon.Entity.Source? = nil, - fieldsAttributes: [Mastodon.Entity.Field]? = nil - ) { - self.discoverable = discoverable - self.bot = bot - self.displayName = displayName - self.note = note - self.avatar = avatar?.base64EncondedString - self.header = header?.base64EncondedString - self.locked = locked - self.source = source - self.fieldsAttributes = fieldsAttributes - } - } - - public struct AccountInfoQuery: GetQuery { - - public let id: String - - var queryItems: [URLQueryItem]? { nil } - } -} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5a55ee103..073d926e9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -140,8 +140,13 @@ extension Mastodon.API { timeoutInterval: Mastodon.API.timeoutInterval ) request.httpMethod = method.rawValue - request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.httpBody = query?.body + if let contentType = query?.contentType { + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + if let body = query?.body { + request.httpBody = body + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + } if let authorization = authorization { request.setValue( "Bearer \(authorization.accessToken)", diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift new file mode 100644 index 000000000..43354394d --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -0,0 +1,37 @@ +// +// Data.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +extension Data { + + static func multipart( + boundary: String = Multipart.boundary, + key: String, + value: MultipartFormValue + ) -> Data { + var data = Data() + data.append("\r\n--\(boundary)\r\n".data(using: .utf8)!) + data.append("Content-Disposition: form-data; name=\"\(key)\"".data(using: .utf8)!) + if let filename = value.multipartFilename { + data.append("; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + } else { + data.append("\r\n".data(using: .utf8)!) + } + if let contentType = value.multipartContentType { + data.append("Content-Type: \(contentType)\r\n".data(using: .utf8)!) + } + data.append("\r\n".data(using: .utf8)!) + data.append(value.multipartValue) + return data + } + + static func multipartEnd(boundary: String = Multipart.boundary) -> Data { + return "\r\n--\(boundary)--\r\n".data(using: .utf8)! + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift index 0c5e90609..b64d3726e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Mastodon.swift +++ b/MastodonSDK/Sources/MastodonSDK/Mastodon.swift @@ -12,4 +12,5 @@ public enum Mastodon { public enum Response { } public enum API { } public enum Entity { } + public enum Query { } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift similarity index 66% rename from MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift rename to MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index 39ffe23d7..f3bd88832 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+MediaAttachment.swift +// MediaAttachment.swift // // // Created by jk234ert on 2/9/21. @@ -7,7 +7,7 @@ import Foundation -extension Mastodon.Entity { +extension Mastodon.Query { public enum MediaAttachment { /// JPEG (Joint Photographic Experts Group) image case jpeg(Data?) @@ -20,7 +20,7 @@ extension Mastodon.Entity { } } -extension Mastodon.Entity.MediaAttachment { +extension Mastodon.Query.MediaAttachment { var data: Data? { switch self { case .jpeg(let data): return data @@ -31,11 +31,12 @@ extension Mastodon.Entity.MediaAttachment { } var fileName: String { + let name = UUID().uuidString switch self { - case .jpeg: return "file.jpg" - case .gif: return "file.gif" - case .png: return "file.png" - case .other(_, let fileExtension, _): return "file.\(fileExtension)" + case .jpeg: return "\(name).jpg" + case .gif: return "\(name).gif" + case .png: return "\(name).png" + case .other(_, let fileExtension, _): return "\(name).\(fileExtension)" } } @@ -53,3 +54,8 @@ extension Mastodon.Entity.MediaAttachment { } } +extension Mastodon.Query.MediaAttachment: MultipartFormValue { + var multipartValue: Data { return data ?? Data() } + var multipartContentType: String? { return mimeType } + var multipartFilename: String? { return fileName } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift new file mode 100644 index 000000000..fd9a9c8f4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift @@ -0,0 +1,37 @@ +// +// MultipartFormValue.swift +// +// +// Created by MainasuK Cirno on 2021-3-8. +// + +import Foundation + +enum Multipart { + static let boundary = "__boundary__" +} + +protocol MultipartFormValue { + var multipartValue: Data { get } + var multipartContentType: String? { get } + var multipartFilename: String? { get } +} + +extension Bool: MultipartFormValue { + var multipartValue: Data { + switch self { + case true: return "true".data(using: .utf8)! + case false: return "false".data(using: .utf8)! + } + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} + +extension String: MultipartFormValue { + var multipartValue: Data { + return self.data(using: .utf8)! + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift similarity index 61% rename from MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift rename to MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 7a6608d66..a0a5e4eae 100644 --- a/MastodonSDK/Sources/MastodonSDK/Protocol/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -14,12 +14,22 @@ enum RequestMethod: String { protocol RequestQuery { // All kinds of queries could have queryItems and body var queryItems: [URLQueryItem]? { get } + var contentType: String? { get } var body: Data? { get } } +extension RequestQuery { + static func multipartContentType(boundary: String = Multipart.boundary) -> String { + return "multipart/form-data; charset=utf-8; boundary=\"\(boundary)\"" + } +} + // An `Encodable` query provides its body by encoding itself // A `Get` query only contains queryItems, it should not be `Encodable` extension RequestQuery where Self: Encodable { + var contentType: String? { + return "application/json; charset=utf-8" + } var body: Data? { return try? Mastodon.API.encoder.encode(self) } @@ -30,18 +40,20 @@ protocol GetQuery: RequestQuery { } extension GetQuery { // By default a `GetQuery` does not has data body var body: Data? { nil } + var contentType: String? { nil } } -protocol PostQuery: RequestQuery & Encodable { } +protocol PostQuery: RequestQuery { } extension PostQuery { - // By default a `GetQuery` does not has query items + // By default a `PostQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } -protocol PatchQuery: RequestQuery & Encodable { } +protocol PatchQuery: RequestQuery { } extension PatchQuery { - // By default a `GetQuery` does not has query items + // By default a `PatchQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } + diff --git a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift index 08607aed8..b113672ec 100644 --- a/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift +++ b/MastodonSDK/Tests/MastodonSDKTests/API/MastodonSDK+API+AccountTests.swift @@ -8,9 +8,11 @@ import os.log import XCTest import Combine +import UIKit @testable import MastodonSDK extension MastodonSDKTests { + func testVerifyCredentials() throws { let theExpectation = expectation(description: "Verify Account Credentials") @@ -44,11 +46,14 @@ extension MastodonSDKTests { .flatMap({ (result) -> AnyPublisher, Error> in // TODO: replace with test account acct - XCTAssertEqual(result.value.acct, "") + XCTAssert(!result.value.acct.isEmpty) theExpectation1.fulfill() - - var query = Mastodon.API.Account.UpdateCredentialQuery() - query.note = dateString + + let query = Mastodon.API.Account.UpdateCredentialQuery( + bot: !(result.value.bot ?? false), + note: dateString, + header: Mastodon.Query.MediaAttachment.jpeg(UIImage(systemName: "house")!.jpegData(compressionQuality: 0.8)) + ) return Mastodon.API.Account.updateCredentials(session: self.session, domain: self.domain, query: query, authorization: authorization) }) .sink { completion in @@ -73,8 +78,7 @@ extension MastodonSDKTests { func testRetrieveAccountInfo() throws { let theExpectation = expectation(description: "Verify Account Credentials") - let query = Mastodon.API.Account.AccountInfoQuery(id: "1") - Mastodon.API.Account.accountInfo(session: session, domain: domain, query: query, authorization: nil) + Mastodon.API.Account.accountInfo(session: session, domain: "mastodon.online", userID: "1", authorization: nil) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -91,4 +95,5 @@ extension MastodonSDKTests { wait(for: [theExpectation], timeout: 5.0) } + } From 1a66ba92c0a38c68795b40ab7ed07a365b771fb9 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 8 Mar 2021 19:05:15 +0800 Subject: [PATCH 052/400] feat: add avatar and display name update logic after sign-up flow --- .../MastodonConfirmEmailViewController.swift | 19 ++++++++++++++- .../MastodonConfirmEmailViewModel.swift | 11 ++++++++- .../MastodonRegisterViewController.swift | 24 +++++++++++++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 2d69f0dd3..dee1510cd 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -111,7 +111,24 @@ extension MastodonConfirmEmailViewController { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - break + // upload avatar and set display name in the background + self.context.apiService.accountUpdateCredentials( + domain: self.viewModel.authenticateInfo.domain, + query: self.viewModel.updateCredentialQuery, + authorization: Mastodon.API.OAuth.Authorization(accessToken: self.viewModel.userToken.accessToken) + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup avatar & display name success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) // execute in the background } } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: user %s's email confirmed", ((#file as NSString).lastPathComponent), #line, #function, response.value.username) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift index aff254741..9fbd24eda 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewModel.swift @@ -12,20 +12,29 @@ import MastodonSDK final class MastodonConfirmEmailViewModel { var disposeBag = Set() + // input let context: AppContext var email: String let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let userToken: Mastodon.Entity.Token + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery let timestampUpdatePublisher = Timer.publish(every: 4.0, on: .main, in: .common) .autoconnect() .share() .eraseToAnyPublisher() - init(context: AppContext, email: String, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, userToken: Mastodon.Entity.Token) { + init( + context: AppContext, + email: String, + authenticateInfo: AuthenticationViewModel.AuthenticateInfo, + userToken: Mastodon.Entity.Token, + updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery + ) { self.context = context self.email = email self.authenticateInfo = authenticateInfo self.userToken = userToken + self.updateCredentialQuery = updateCredentialQuery } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index d1ef11c67..f078e9b8d 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -5,12 +5,12 @@ // Created by MainasuK Cirno on 2021-2-5. // +import AlamofireImage import Combine import MastodonSDK import os.log import PhotosUI import UIKit -import UITextField_Shake final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { var disposeBag = Set() @@ -623,10 +623,10 @@ extension MastodonRegisterViewController { username: username, email: email, password: password, - agreement: true, // TODO: - locale: "en" // TODO: + agreement: true, // user confirmed in the server rules scene + locale: Locale.current.languageCode ?? "en" ) - + // register without show server rules context.apiService.accountRegister( domain: viewModel.domain, @@ -646,7 +646,21 @@ extension MastodonRegisterViewController { } receiveValue: { [weak self] response in guard let self = self else { return } let userToken = response.value - let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken) + let updateCredentialQuery: Mastodon.API.Account.UpdateCredentialQuery = { + let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value + let avatar: Mastodon.Query.MediaAttachment? = { + guard let avatarImage = self.viewModel.avatarImage.value else { return nil } + guard avatarImage.size.width <= 400 else { + return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8)) + } + return .jpeg(avatarImage.jpegData(compressionQuality: 0.8)) + }() + return Mastodon.API.Account.UpdateCredentialQuery( + displayName: displayName, + avatar: avatar + ) + }() + let viewModel = MastodonConfirmEmailViewModel(context: self.context, email: email, authenticateInfo: self.viewModel.authenticateInfo, userToken: userToken, updateCredentialQuery: updateCredentialQuery) self.coordinator.present(scene: .mastodonConfirmEmail(viewModel: viewModel), from: self, transition: .show) } .store(in: &disposeBag) From 8bce19713665603ddf33fbae07b2024b3a8aecb1 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 8 Mar 2021 11:21:34 +0800 Subject: [PATCH 053/400] chore: make media_attachments.preview_url optional It's null when toot has audio So the document is wrong --- CoreDataStack/Entity/Attachment.swift | 6 +++--- Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift | 3 ++- .../MastodonSDK/Entity/Mastodon+Entity+Attachment.swift | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index e580014c1..33a0c0826 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -15,7 +15,7 @@ public final class Attachment: NSManagedObject { @NSManaged public private(set) var domain: String @NSManaged public private(set) var typeRaw: String @NSManaged public private(set) var url: String - @NSManaged public private(set) var previewURL: String + @NSManaged public private(set) var previewURL: String? @NSManaged public private(set) var remoteURL: String? @NSManaged public private(set) var metaData: Data? @@ -80,7 +80,7 @@ public extension Attachment { public let typeRaw: String public let url: String - public let previewURL: String + public let previewURL: String? public let remoteURL: String? public let metaData: Data? public let textURL: String? @@ -95,7 +95,7 @@ public extension Attachment { id: Attachment.ID, typeRaw: String, url: String, - previewURL: String, + previewURL: String?, remoteURL: String?, metaData: Data?, textURL: String?, diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index aa9d79c73..ce92ccb77 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -16,7 +16,8 @@ struct MosaicImageViewModel { var metas: [MosaicMeta] = [] for element in mediaAttachments where element.type == .image { // Display original on the iPad/Mac - let urlString = UIDevice.current.userInterfaceIdiom == .phone ? element.previewURL : element.url + guard let previewURL = element.previewURL else { continue } + let urlString = UIDevice.current.userInterfaceIdiom == .phone ? previewURL : element.url guard let meta = element.meta, let width = meta.original?.width, let height = meta.original?.height, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index 2a09ccfc8..bb03ba1bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -23,7 +23,7 @@ extension Mastodon.Entity { public let id: ID public let type: Type public let url: String - public let previewURL: String + public let previewURL: String? public let remoteURL: String? public let textURL: String? From 30d03a38945b168bda8b2c73ebbd746ddd7da79e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 8 Mar 2021 11:42:10 +0800 Subject: [PATCH 054/400] chore: add audio support for toot --- .../CoreData.xcdatamodel/contents | 6 +- Mastodon.xcodeproj/project.pbxproj | 28 +++++ .../Diffiable/Section/StatusSection.swift | 15 ++- Mastodon/Extension/Double.swift | 19 +++ Mastodon/Extension/UIControl.swift | 64 ++++++++++ Mastodon/Extension/UIImage.swift | 35 ++++++ Mastodon/Generated/Assets.swift | 3 + .../Colors/Slider/Contents.json | 9 ++ .../Colors/Slider/bar.colorset/Contents.json | 20 +++ .../View/Container/AudioContainerView.swift | 114 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 13 ++ .../ViewModel/AudioContainerViewModel.swift | 85 +++++++++++++ Mastodon/Service/AudioPlayer.swift | 113 +++++++++++++++++ Mastodon/Service/PlaybackState.swift | 25 ++++ 14 files changed, 542 insertions(+), 7 deletions(-) create mode 100644 Mastodon/Extension/Double.swift create mode 100644 Mastodon/Extension/UIControl.swift create mode 100644 Mastodon/Extension/UIImage.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Container/AudioContainerView.swift create mode 100644 Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift create mode 100644 Mastodon/Service/AudioPlayer.swift create mode 100644 Mastodon/Service/PlaybackState.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 3f8fe73f9..be40ac57d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@ - + @@ -163,7 +163,7 @@ - + diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b3abfd84a..1653f6241 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -22,6 +22,11 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; + 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlayer.swift */; }; + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; @@ -74,6 +79,8 @@ 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; + 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; @@ -255,6 +262,11 @@ 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; + 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; + 2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -303,6 +315,8 @@ 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; + 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -637,6 +651,8 @@ DB45FB0425CA87B4005A8AC7 /* APIService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, + 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, + 2DA6054625F716A2006356F9 /* PlaybackState.swift */, ); path = Service; sourceTree = ""; @@ -1097,6 +1113,9 @@ 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, + 2D206B7F25F5F45E00143C56 /* UIImage.swift */, + 2D206B8525F5FB0900143C56 /* Double.swift */, + 2D206B9125F60EA700143C56 /* UIControl.swift */, ); path = Extension; sourceTree = ""; @@ -1148,6 +1167,7 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, + 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1156,6 +1176,7 @@ isa = PBXGroup; children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, + 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1534,6 +1555,7 @@ files = ( DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, + 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, @@ -1571,6 +1593,7 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, + 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, @@ -1585,8 +1608,10 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, + 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, @@ -1596,6 +1621,7 @@ DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, @@ -1621,6 +1647,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, @@ -1642,6 +1669,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonContent.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5f9d43ed5..489b9d3b8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -127,8 +127,8 @@ extension StatusSection { }() let scale: CGFloat = { switch mosiacImageViewModel.metas.count { - case 1: return 1.3 - default: return 0.7 + case 1: return 1.3 + default: return 0.7 } }() return CGSize(width: maxWidth, height: maxWidth * scale) @@ -157,6 +157,14 @@ extension StatusSection { cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + // set audio + if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { + cell.statusView.audioView.isHidden = false + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment) + } else { + cell.statusView.audioView.isHidden = true + } + // set poll let poll = (toot.reblog ?? toot).poll StatusSection.configure( @@ -171,7 +179,7 @@ extension StatusSection { .sink { _ in // do nothing } receiveValue: { change in - guard case let .update(object) = change.changeType, + guard case .update(let object) = change.changeType, let newPoll = object as? Poll else { return } StatusSection.configure( cell: cell, @@ -336,7 +344,6 @@ extension StatusSection { snapshot.appendItems(pollItems, toSection: .main) cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } - } extension StatusSection { diff --git a/Mastodon/Extension/Double.swift b/Mastodon/Extension/Double.swift new file mode 100644 index 000000000..f485ec2d9 --- /dev/null +++ b/Mastodon/Extension/Double.swift @@ -0,0 +1,19 @@ +// +// Double.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation + +extension Double { + func asString(style: DateComponentsFormatter.UnitsStyle) -> String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = style + formatter.zeroFormattingBehavior = .pad + guard let formattedString = formatter.string(from: self) else { return "" } + return formattedString + } +} diff --git a/Mastodon/Extension/UIControl.swift b/Mastodon/Extension/UIControl.swift new file mode 100644 index 000000000..792e82508 --- /dev/null +++ b/Mastodon/Extension/UIControl.swift @@ -0,0 +1,64 @@ +// +// UIControl.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import Foundation +import UIKit +import Combine + +/// A custom subscription to capture UIControl target events. +final class UIControlSubscription: Subscription where SubscriberType.Input == Control { + private var subscriber: SubscriberType? + private let control: Control + + init(subscriber: SubscriberType, control: Control, event: UIControl.Event) { + self.subscriber = subscriber + self.control = control + control.addTarget(self, action: #selector(eventHandler), for: event) + } + + func request(_ demand: Subscribers.Demand) { + // We do nothing here as we only want to send events when they occur. + // See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand + } + + func cancel() { + subscriber = nil + } + + @objc private func eventHandler() { + _ = subscriber?.receive(control) + } +} + +/// A custom `Publisher` to work with our custom `UIControlSubscription`. +struct UIControlPublisher: Publisher { + + typealias Output = Control + typealias Failure = Never + + let control: Control + let controlEvents: UIControl.Event + + init(control: Control, events: UIControl.Event) { + self.control = control + self.controlEvents = events + } + + func receive(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output { + let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents) + subscriber.receive(subscription: subscription) + } +} + +/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher. +protocol CombineCompatible { } +extension UIControl: CombineCompatible { } +extension CombineCompatible where Self: UIControl { + func publisher(for events: UIControl.Event) -> UIControlPublisher { + return UIControlPublisher(control: self, events: events) + } +} diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift new file mode 100644 index 000000000..e821b676c --- /dev/null +++ b/Mastodon/Extension/UIImage.swift @@ -0,0 +1,35 @@ +// +// UIImage.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import UIKit + +extension UIImage { + class func imageWithColor(color: UIColor, size: CGSize=CGSize(width: 1, height: 1)) -> UIImage? { + UIGraphicsBeginImageContextWithOptions(size, false, 0) + color.setFill() + UIRectFill(CGRect(origin: CGPoint.zero, size: size)) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } + public func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { + let maxRadius = min(size.width, size.height) / 2 + let cornerRadius: CGFloat + if let radius = radius, radius > 0 && radius <= maxRadius { + cornerRadius = radius + } else { + cornerRadius = maxRadius + } + UIGraphicsBeginImageContextWithOptions(size, false, scale) + let rect = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() + draw(in: rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 32786b40d..f68170460 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -58,6 +58,9 @@ internal enum Asset { internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } + internal enum Slider { + internal static let bar = ColorAsset(name: "Colors/Slider/bar") + } internal enum TextField { internal static let highlight = ColorAsset(name: "Colors/TextField/highlight") internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json new file mode 100644 index 000000000..dc91052f7 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Slider/bar.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "147", + "green" : "106", + "red" : "51" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift new file mode 100644 index 000000000..cddd7871b --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -0,0 +1,114 @@ +// +// AudioViewContainer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import os.log +import CoreDataStack +import UIKit + + +final class AudioContainerView: UIView { + + static let cornerRadius: CGFloat = 22 + + let container: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 11 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 9, bottom: 0, right: 9) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layer.cornerRadius = AudioContainerView.cornerRadius + stackView.clipsToBounds = true + stackView.backgroundColor = Asset.Colors.Button.highlight.color + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.layer.cornerRadius = 16 + view.clipsToBounds = true + view.backgroundColor = Asset.Colors.Button.highlight.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let playButton: UIButton = { + let button = UIButton(type: .custom) + let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) + + let pauseImage = UIImage(systemName: "pause.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! + button.setImage(pauseImage.withRenderingMode(.alwaysTemplate), for: .selected) + + button.tintColor = .white + button.translatesAutoresizingMaskIntoConstraints = false + button.isEnabled = true + return button + }() + + let slider: UISlider = { + let slider = UISlider() + slider.translatesAutoresizingMaskIntoConstraints = false + slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color + slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color + if let image = UIImage.imageWithColor(color: .white, size: CGSize(width: 22, height: 22))?.withRoundedCorners(radius: 11) { + slider.setThumbImage(image, for: .normal) + } + return slider + }() + + let timeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = .white + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AudioContainerView { + + private func _init() { + + addSubview(container) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + //checkmark + checkmarkBackgroundView.addSubview(playButton) + container.addArrangedSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + playButton.centerXAnchor.constraint(equalTo: checkmarkBackgroundView.centerXAnchor), + playButton.centerYAnchor.constraint(equalTo: checkmarkBackgroundView.centerYAnchor), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: 32), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: 32), + ]) + + container.addArrangedSubview(slider) + + container.addArrangedSubview(timeLabel) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index c1f3cb3d0..2713647fe 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -156,6 +156,10 @@ final class StatusView: UIView { return imageView }() + let audioView: AudioContainerView = { + let audioView = AudioContainerView() + return audioView + }() let actionToolbarContainer: ActionToolbarContainer = { let actionToolbarContainer = ActionToolbarContainer() actionToolbarContainer.configure(for: .inline) @@ -338,6 +342,14 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + audioView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(audioView) + NSLayoutConstraint.activate([ + audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), + audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), + audioView.heightAnchor.constraint(equalToConstant: 44) + ]) + // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -346,6 +358,7 @@ extension StatusView { statusMosaicImageViewContainer.isHidden = true pollTableView.isHidden = true pollStatusStackView.isHidden = true + audioView.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift new file mode 100644 index 000000000..ce8d61def --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -0,0 +1,85 @@ +// +// AudioContainerViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import Foundation +import CoreDataStack +import UIKit + +class AudioContainerViewModel { + static func configure( + cell: StatusTableViewCell, + audioAttachment: Attachment + ) { + guard let duration = audioAttachment.meta?.original?.duration else { return } + let audioView = cell.statusView.audioView + audioView.timeLabel.text = duration.asString(style: .positional) + + audioView.playButton.publisher(for: .touchUpInside) + .sink { button in + if (button.isSelected) { + AudioPlayer.shared.pause() + } else { + if audioAttachment === AudioPlayer.shared.attachment { + if AudioPlayer.shared.currentTimeSubject.value == 0 { + AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + } else { + AudioPlayer.shared.resume() + } + } else { + AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + } + } + } + .store(in: &cell.disposeBag) + audioView.slider.publisher(for: .valueChanged) + .sink { slider in + let slider = slider as! UISlider + let time = Double(slider.value) * duration + + AudioPlayer.shared.seekToTime(time: time) + } + .store(in: &cell.disposeBag) + self.observePlayer(cell:cell, audioAttachment: audioAttachment) + if audioAttachment != AudioPlayer.shared.attachment { + self.resetAudioView(audioView: audioView) + } + } + static func observePlayer( + cell: StatusTableViewCell, + audioAttachment: Attachment + ) { + let audioView = cell.statusView.audioView + AudioPlayer.shared.currentTimeSubject + .receive(on: DispatchQueue.main) + .filter { _ in + audioAttachment === AudioPlayer.shared.attachment + } + .sink(receiveValue: { time in + audioView.timeLabel.text = time.asString(style: .positional) + if let duration = audioAttachment.meta?.original?.duration, !audioView.slider.isTracking { + audioView.slider.setValue(Float(time/duration), animated: true) + } + }) + .store(in: &cell.disposeBag) + AudioPlayer.shared.playbackState + .map { + return $0 == .playing || $0 == .readyToPlay + } + .sink(receiveValue: { isPlaying in + if (audioAttachment === AudioPlayer.shared.attachment) { + audioView.playButton.isSelected = isPlaying + } else { + self.resetAudioView(audioView: audioView) + } + }) + .store(in: &cell.disposeBag) + } + static func resetAudioView(audioView:AudioContainerView) { + audioView.playButton.isSelected = false + audioView.slider.setValue(0, animated: false) + } +} diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift new file mode 100644 index 000000000..4a14a080b --- /dev/null +++ b/Mastodon/Service/AudioPlayer.swift @@ -0,0 +1,113 @@ +// +// AudioPlayer.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/8. +// + +import AVFoundation +import Combine +import CoreDataStack +import Foundation +import UIKit + +final class AudioPlayer: NSObject { + var disposeBag = Set() + + var player = AVPlayer() + var timeObserver: Any? + var statusObserver: Any? + var attachment: Attachment? + var currentURL: URL? + let session = AVAudioSession.sharedInstance() + let playbackState = CurrentValueSubject(PlaybackState.unknown) + public static let shared = AudioPlayer() + + let currentTimeSubject = CurrentValueSubject(0) + + override init() { + super.init() + addObserver() + } +} + +extension AudioPlayer { + func playAudio(audioAttachment: Attachment) { + guard let url = URL(string: audioAttachment.url) else { + return + } + do { + try session.setCategory(.playback) + } catch { + print(error) + return + } + + if audioAttachment == attachment { + player.play() + return + } + + let playerItem = AVPlayerItem(url: url) + player.replaceCurrentItem(with: playerItem) + attachment = audioAttachment + player.play() + playbackState.send(PlaybackState.playing) + } + + func addObserver() { + UIDevice.current.isProximityMonitoringEnabled = true + NotificationCenter.default.addObserver(self, selector: #selector(proxumityStateChange), name: UIDevice.proximityStateDidChangeNotification, object: nil) + + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in + guard let self = self else { return } + self.currentTimeSubject.value = time.seconds + }) + player.publisher(for: \.status, options: .new) + .sink(receiveValue: { status in + switch status { + case .failed: + self.playbackState.value = .failed + case .readyToPlay: + self.playbackState.value = .readyToPlay + case .unknown: + self.playbackState.value = .unknown + @unknown default: + fatalError() + } + }) + .store(in: &disposeBag) + } + + @objc func proxumityStateChange(notification: NSNotification) { + if UIDevice.current.proximityState == true { + do { + try session.setCategory(.playAndRecord) + } catch { + print(error) + return + } + } else { + do { + try session.setCategory(.playback) + } catch { + print(error) + return + } + } + } + + func resume() { + player.play() + playbackState.send(PlaybackState.playing) + } + + func pause() { + player.pause() + playbackState.send(PlaybackState.paused) + } + + func seekToTime(time: TimeInterval) { + player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) + } +} diff --git a/Mastodon/Service/PlaybackState.swift b/Mastodon/Service/PlaybackState.swift new file mode 100644 index 000000000..75fced7bb --- /dev/null +++ b/Mastodon/Service/PlaybackState.swift @@ -0,0 +1,25 @@ +// +// PlaybackState.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/9. +// + +import Foundation + +public enum PlaybackState : Int { + + case unknown = 0 + + case buffering = 1 + + case readyToPlay = 2 + + case playing = 3 + + case paused = 4 + + case stopped = 5 + + case failed = 6 +} From 2d4dbad5350ae52dc07fbc4abd8616b34cfa3c62 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 15:18:36 +0800 Subject: [PATCH 055/400] chore: fix slider shake, reset audioView when stoped --- .../View/Container/AudioContainerView.swift | 3 ++ .../ViewModel/AudioContainerViewModel.swift | 10 ++-- Mastodon/Service/AudioPlayer.swift | 48 +++++++++++-------- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index cddd7871b..e20f5cca0 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -109,6 +109,9 @@ extension AudioContainerView { container.addArrangedSubview(slider) container.addArrangedSubview(timeLabel) + NSLayoutConstraint.activate([ + timeLabel.widthAnchor.constraint(equalToConstant: 40), + ]) } } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index ce8d61def..8d4e9e2a5 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -66,12 +66,14 @@ class AudioContainerViewModel { }) .store(in: &cell.disposeBag) AudioPlayer.shared.playbackState - .map { - return $0 == .playing || $0 == .readyToPlay - } - .sink(receiveValue: { isPlaying in + .receive(on: DispatchQueue.main) + .sink(receiveValue: { playbackState in if (audioAttachment === AudioPlayer.shared.attachment) { + let isPlaying = playbackState == .playing || playbackState == .readyToPlay audioView.playButton.isSelected = isPlaying + if playbackState == .stopped { + self.resetAudioView(audioView: audioView) + } } else { self.resetAudioView(audioView: audioView) } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 4a14a080b..95be2e78a 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -57,14 +57,34 @@ extension AudioPlayer { func addObserver() { UIDevice.current.isProximityMonitoringEnabled = true - NotificationCenter.default.addObserver(self, selector: #selector(proxumityStateChange), name: UIDevice.proximityStateDidChangeNotification, object: nil) - + NotificationCenter.default.publisher(for: UIDevice.proximityStateDidChangeNotification, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + if UIDevice.current.proximityState == true { + do { + try self.session.setCategory(.playAndRecord) + } catch { + print(error) + return + } + } else { + do { + try self.session.setCategory(.playback) + } catch { + print(error) + return + } + } + } + .store(in: &disposeBag) + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in guard let self = self else { return } self.currentTimeSubject.value = time.seconds }) player.publisher(for: \.status, options: .new) - .sink(receiveValue: { status in + .sink(receiveValue: { [weak self] status in + guard let self = self else { return } switch status { case .failed: self.playbackState.value = .failed @@ -77,25 +97,13 @@ extension AudioPlayer { } }) .store(in: &disposeBag) + NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) + .sink { _ in + self.playbackState.send(PlaybackState.stopped) + } + .store(in: &disposeBag) } - @objc func proxumityStateChange(notification: NSNotification) { - if UIDevice.current.proximityState == true { - do { - try session.setCategory(.playAndRecord) - } catch { - print(error) - return - } - } else { - do { - try session.setCategory(.playback) - } catch { - print(error) - return - } - } - } func resume() { player.play() From 441a6aee9e0286f389d23083b43a0bb7c3c79885 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 15:18:43 +0800 Subject: [PATCH 056/400] feat: implement boost for toot --- CoreDataStack/Entity/Toot.swift | 8 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 65 +++--- Mastodon/Generated/Assets.swift | 1 + ...Provider+StatusTableViewCellDelegate.swift | 4 + .../StatusProvider+UITableViewDelegate.swift | 1 + .../StatusProvider/StatusProviderFacade.swift | 110 ++++++++++- .../system.green.colorset/Contents.json | 20 ++ .../TableviewCell/StatusTableViewCell.swift | 5 +- .../View/ToolBar/ActionToolBarContainer.swift | 69 ++++--- .../APIService/APIService+Favorite.swift | 5 +- .../APIService/APIService+Reblog.swift | 169 ++++++++++++++++ .../API/Mastodon+API+Favorites.swift | 6 +- .../API/Mastodon+API+Status+Reblog.swift | 186 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API+Status.swift | 12 ++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 16 files changed, 598 insertions(+), 68 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json create mode 100644 Mastodon/Service/APIService/APIService+Reblog.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index c5fcf4869..56d112d74 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -163,7 +163,7 @@ public extension Toot { func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -174,7 +174,7 @@ public extension Toot { func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { @@ -186,7 +186,7 @@ public extension Toot { func update(muted: Bool, mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { @@ -198,7 +198,7 @@ public extension Toot { func update(bookmarked: Bool, mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).addObjects(from: [mastodonUser]) + self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b3abfd84a..448d81a51 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -183,6 +183,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -425,6 +426,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -906,6 +908,7 @@ DB98337E25C9452D00AD9700 /* APIService+APIError.swift */, DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */, 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */, + DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */, DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, @@ -1663,6 +1666,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5f9d43ed5..3bdfffc56 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -159,7 +159,7 @@ extension StatusSection { // set poll let poll = (toot.reblog ?? toot).poll - StatusSection.configure( + StatusSection.configurePoll( cell: cell, poll: poll, requestUserID: requestUserID, @@ -173,7 +173,7 @@ extension StatusSection { } receiveValue: { change in guard case let .update(object) = change.changeType, let newPoll = object as? Poll else { return } - StatusSection.configure( + StatusSection.configurePoll( cell: cell, poll: newPoll, requestUserID: requestUserID, @@ -185,19 +185,7 @@ extension StatusSection { } // toolbar - let replyCountTitle: String = { - let count = (toot.reblog ?? toot).repliesCount?.intValue ?? 0 - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - - let isLike = (toot.reblog ?? toot).favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCountTitle: String = { - let count = (toot.reblog ?? toot).favouritesCount.intValue - return StatusSection.formattedNumberTitleForActionButton(count) - }() - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) // set date let createdAt = (toot.reblog ?? toot).createdAt @@ -215,20 +203,47 @@ extension StatusSection { // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let newToot = object as? Toot else { return } - let targetToot = newToot.reblog ?? newToot - - let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let favoriteCount = targetToot.favouritesCount.intValue - let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount) - cell.statusView.actionToolbarContainer.starButton.setTitle(favoriteCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isStarButtonHighlight = isLike - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, targetToot.id, favoriteCount) + let toot = object as? Toot else { return } + StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + + os_log("%{public}s[%{public}ld], %{public}s: boost count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) } .store(in: &cell.disposeBag) } - static func configure( + static func configureActionToolBar( + cell: StatusTableViewCell, + toot: Toot, + requestUserID: String + ) { + let toot = toot.reblog ?? toot + + // set reply + let replyCountTitle: String = { + let count = toot.repliesCount?.intValue ?? 0 + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) + // set boost + let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let boostCountTitle: String = { + let count = toot.reblogsCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.boostButton.setTitle(boostCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isBoostButtonHighlight = isBoosted + // set like + let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let favoriteCountTitle: String = { + let count = toot.favouritesCount.intValue + return StatusSection.formattedNumberTitleForActionButton(count) + }() + cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + } + + static func configurePoll( cell: StatusTableViewCell, poll: Poll?, requestUserID: String, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 32786b40d..a0831c9a7 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -74,6 +74,7 @@ internal enum Asset { internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") + internal static let systemGreen = ColorAsset(name: "Colors/system.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Welcome { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cd4e5160d..e768ae3c9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -16,6 +16,10 @@ import ActiveLabel // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusBoostAction(provider: self, cell: cell) + } + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 93f627c09..13bb63f06 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -17,6 +17,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // update poll when toot appear let now = Date() var pollID: Mastodon.Entity.Poll.ID? toot(for: cell, indexPath: indexPath) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 894461566..cc5589978 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -16,6 +16,7 @@ import ActiveLabel enum StatusProviderFacade { } + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { @@ -56,10 +57,9 @@ extension StatusProviderFacade { toot .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot else { return nil } + guard let toot = toot?.reblog ?? toot else { return nil } let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let targetToot = (toot.reblog ?? toot) - let isLiked = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() return (toot.objectID, favoriteKind) @@ -120,6 +120,110 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + + static func responseToStatusBoostAction(provider: StatusProvider) { + _responseToStatusBoostAction( + provider: provider, + toot: provider.toot() + ) + } + + static func responseToStatusBoostAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusBoostAction( + provider: provider, + toot: provider.toot(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusBoostAction(provider: StatusProvider, toot: Future) { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return + } + + // prepare current user infos + guard let _currentMastodonUser = provider.context.authenticationService.activeMastodonAuthentication.value?.user else { + assertionFailure() + return + } + let mastodonUserID = activeMastodonAuthenticationBox.userID + assert(_currentMastodonUser.id == mastodonUserID) + let mastodonUserObjectID = _currentMastodonUser.objectID + + guard let context = provider.context else { return } + + // haptic feedback generator + let generator = UIImpactFeedbackGenerator(style: .light) + let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) + + toot + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Status.Reblog.BoostKind)? in + guard let toot = toot?.reblog ?? toot else { return nil } + let boostKind: Mastodon.API.Status.Reblog.BoostKind = { + let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isBoosted ? .undoBoost : .boost + }() + return (toot.objectID, boostKind) + } + .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Status.Reblog.BoostKind), Error> in + return context.apiService.boost( + tootObjectID: tootObjectID, + mastodonUserObjectID: mastodonUserObjectID, + boostKind: boostKind + ) + .map { tootID in (tootID, boostKind) } + .eraseToAnyPublisher() + } + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + .switchToLatest() + .receive(on: DispatchQueue.main) + .handleEvents { _ in + generator.prepare() + responseFeedbackGenerator.prepare() + } receiveOutput: { _, boostKind in + generator.impactOccurred() + os_log("%{public}s[%{public}ld], %{public}s: [Boost] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, boostKind == .boost ? "boost" : "unboost") + } receiveCompletion: { completion in + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } + .map { tootID, boostKind in + return context.apiService.boost( + statusID: tootID, + boostKind: boostKind, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .receive(on: DispatchQueue.main) + .sink { [weak provider] completion in + guard let provider = provider else { return } + if provider.view.window != nil { + responseFeedbackGenerator.impactOccurred() + } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { response in + // do nothing + } + .store(in: &provider.disposeBag) + } + +} + extension StatusProviderFacade { enum Target { case toot diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json new file mode 100644 index 000000000..8716dcb74 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.604", + "green" : "0.741", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 13c3afba4..fdebd5769 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -19,6 +19,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -207,8 +208,8 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) { - + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, boostButtonDidPressed: sender) } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 02f60d518..991592c13 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -10,7 +10,7 @@ import UIKit protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, retootButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -19,12 +19,16 @@ protocol ActionToolbarContainerDelegate: class { final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let retootButton = HitTestExpandedButton() - let starButton = HitTestExpandedButton() + let boostButton = HitTestExpandedButton() + let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isStarButtonHighlight: Bool = false { - didSet { isStarButtonHighlightStateDidChange(to: isStarButtonHighlight) } + var isBoostButtonHighlight: Bool = false { + didSet { isBoostButtonHighlightStateDidChange(to: isBoostButtonHighlight) } + } + + var isFavoriteButtonHighlight: Bool = false { + didSet { isFavoriteButtonHighlightStateDidChange(to: isFavoriteButtonHighlight) } } weak var delegate: ActionToolbarContainerDelegate? @@ -57,8 +61,8 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - retootButton.addTarget(self, action: #selector(ActionToolbarContainer.retootButtonDidPressed(_:)), for: .touchUpInside) - starButton.addTarget(self, action: #selector(ActionToolbarContainer.starButtonDidPressed(_:)), for: .touchUpInside) + boostButton.addTarget(self, action: #selector(ActionToolbarContainer.boostButtonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } @@ -89,7 +93,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, retootButton, starButton, moreButton] + let buttons = [replyButton, boostButton, favoriteButton, moreButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -109,28 +113,28 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .leading } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + boostButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) moreButton.setImage(moreImage, for: .normal) container.axis = .horizontal container.distribution = .fill replyButton.translatesAutoresizingMaskIntoConstraints = false - retootButton.translatesAutoresizingMaskIntoConstraints = false - starButton.translatesAutoresizingMaskIntoConstraints = false + boostButton.translatesAutoresizingMaskIntoConstraints = false + favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(boostButton) + container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: retootButton.heightAnchor).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: starButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: boostButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: retootButton.widthAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: starButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: boostButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), ]) moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) moreButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) @@ -140,16 +144,16 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - retootButton.setImage(reblogImage, for: .normal) - starButton.setImage(starImage, for: .normal) + boostButton.setImage(reblogImage, for: .normal) + favoriteButton.setImage(starImage, for: .normal) container.axis = .horizontal container.spacing = 8 container.distribution = .fillEqually container.addArrangedSubview(replyButton) - container.addArrangedSubview(retootButton) - container.addArrangedSubview(starButton) + container.addArrangedSubview(boostButton) + container.addArrangedSubview(favoriteButton) } } @@ -158,11 +162,18 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isStarButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isBoostButtonHighlightStateDidChange(to isHighlight: Bool) { + let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color + boostButton.tintColor = tintColor + boostButton.setTitleColor(tintColor, for: .normal) + boostButton.setTitleColor(tintColor, for: .highlighted) + } + + private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemOrange.color : Asset.Colors.Button.actionToolbar.color - starButton.tintColor = tintColor - starButton.setTitleColor(tintColor, for: .normal) - starButton.setTitleColor(tintColor, for: .highlighted) + favoriteButton.tintColor = tintColor + favoriteButton.setTitleColor(tintColor, for: .normal) + favoriteButton.setTitleColor(tintColor, for: .highlighted) } } @@ -173,12 +184,12 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func retootButtonDidPressed(_ sender: UIButton) { + @objc private func boostButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, retootButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, boostButtonDidPressed: sender) } - @objc private func starButtonDidPressed(_ sender: UIButton) { + @objc private func favoriteButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.actionToolbarContainer(self, starButtonDidPressed: sender) } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index e1d5febe7..af8f0ffa7 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -78,7 +78,7 @@ extension APIService { }() let _oldToot: Toot? = { let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: entity.id) + request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { @@ -112,7 +112,8 @@ extension APIService { .handleEvents(receiveCompletion: { completion in switch completion { case .failure(let error): - print(error) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) case .finished: break } diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift new file mode 100644 index 000000000..ca47ec713 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -0,0 +1,169 @@ +// +// APIService+Reblog.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine +import MastodonSDK +import CoreData +import CoreDataStack +import CommonOSLog + +extension APIService { + + // make local state change only + func boost( + tootObjectID: NSManagedObjectID, + mastodonUserObjectID: NSManagedObjectID, + boostKind: Mastodon.API.Status.Reblog.BoostKind + ) -> AnyPublisher { + var _targetTootID: Toot.ID? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { + let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let targetToot = toot.reblog ?? toot + let targetTootID = targetToot.id + _targetTootID = targetTootID + + targetToot.update(reblogged: boostKind == .boost, mastodonUser: mastodonUser) + + } + .tryMap { result in + switch result { + case .success: + guard let targetTootID = _targetTootID else { + throw APIError.implicit(.badRequest) + } + return targetTootID + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + // send boost request to remote + func boost( + statusID: Mastodon.Entity.Status.ID, + boostKind: Mastodon.API.Status.Reblog.BoostKind, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + return Mastodon.API.Status.Reblog.boost( + session: session, + domain: domain, + statusID: statusID, + boostKind: boostKind, + authorization: authorization + ) + .map { response -> AnyPublisher, Error> in + let log = OSLog.api + let entity = response.value + let managedObjectContext = self.backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let _requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let _oldToot: Toot? = { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: statusID) + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + guard let requestMastodonUser = _requestMastodonUser, + let oldToot = _oldToot else { + assertionFailure() + return + } + APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .switchToLatest() + .handleEvents(receiveCompletion: { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: error:", ((#file as NSString).lastPathComponent), #line, #function) + debugPrint(error) + case .finished: + break + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { +// func likeList( +// limit: Int = onceRequestTootMaxCount, +// userID: String, +// maxID: String? = nil, +// mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox +// ) -> AnyPublisher, Error> { +// +// let requestMastodonUserID = mastodonAuthenticationBox.userID +// let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) +// return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) +// .map { response -> AnyPublisher, Error> in +// let log = OSLog.api +// +// return APIService.Persist.persistTimeline( +// managedObjectContext: self.backgroundManagedObjectContext, +// domain: mastodonAuthenticationBox.domain, +// query: query, +// response: response, +// persistType: .likeList, +// requestMastodonUserID: requestMastodonUserID, +// log: log +// ) +// .setFailureType(to: Error.self) +// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .switchToLatest() +// .eraseToAnyPublisher() +// } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index ce77a51d9..3b01c2c13 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -114,14 +114,14 @@ extension Mastodon.API.Favorites { } -public extension Mastodon.API.Favorites { +extension Mastodon.API.Favorites { - enum FavoriteKind { + public enum FavoriteKind { case create case destroy } - struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,TimelineQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift new file mode 100644 index 000000000..bc898de9a --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift @@ -0,0 +1,186 @@ +// +// Mastodon+API+Status+Reblog.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation +import Combine + +extension Mastodon.API.Status.Reblog { + + static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblogged_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boosted by + /// + /// View who boosted a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func poll( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: boostedByEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Status.Reblog { + + static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/reblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Boost + /// + /// Reshare a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func boost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + query: BoostQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias Visibility = Mastodon.Entity.Source.Privacy + + public struct BoostQuery: Codable, PostQuery { + public let visibility: Visibility + + public init(visibility: Visibility) { + self.visibility = visibility + } + } + +} + +extension Mastodon.API.Status.Reblog { + + static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + "/unreblog" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Undo boost + /// + /// Undo a reshare of a status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func undoBoost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unreblogEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Status.Reblog { + + public enum BoostKind { + case boost + case undoBoost + } + + public static func boost( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + boostKind: BoostKind, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let url: URL + switch boostKind { + case .boost: url = reblogEndpointURL(domain: domain, statusID: statusID) + case .undoBoost: url = unreblogEndpointURL(domain: domain, statusID: statusID) + } + let request = Mastodon.API.post( + url: url, + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift new file mode 100644 index 000000000..fd3d96277 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift @@ -0,0 +1,12 @@ +// +// Mastodon+API+Status.swift +// +// +// Created by MainasuK Cirno on 2021-3-9. +// + +import Foundation + +extension Mastodon.API.Status { + public enum Reblog { } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5a55ee103..8dbb2c3c7 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -95,6 +95,7 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } + public enum Status { } public enum Timeline { } public enum Favorites { } } From 5a17b8a6eebd8ac50957adc05c5c23125a2666a4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 16:25:47 +0800 Subject: [PATCH 057/400] chore: make slider enable state change with isPlaying --- Mastodon.xcodeproj/project.pbxproj | 4 - Mastodon/Extension/UIIamge.swift | 55 ------------- Mastodon/Extension/UIImage.swift | 77 ++++++++++++++----- .../View/Container/AudioContainerView.swift | 30 +++----- .../ViewModel/AudioContainerViewModel.swift | 22 +++--- .../Entity/Mastodon+Entity+Attachment.swift | 2 +- 6 files changed, 82 insertions(+), 108 deletions(-) delete mode 100644 Mastodon/Extension/UIIamge.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1653f6241..f2db77a66 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -49,7 +49,6 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; - 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46976325C2A71500CF4AA9 /* UIIamge.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; }; @@ -288,7 +287,6 @@ 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIIamge.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = ""; }; @@ -1101,7 +1099,6 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, 2D42FF6A25C817D2004A627A /* MastodonContent.swift */, @@ -1614,7 +1611,6 @@ 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, - 2D46976425C2A71500CF4AA9 /* UIIamge.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, diff --git a/Mastodon/Extension/UIIamge.swift b/Mastodon/Extension/UIIamge.swift deleted file mode 100644 index 4f4b350c3..000000000 --- a/Mastodon/Extension/UIIamge.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// UIIamge.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/28. -// - -import UIKit -import CoreImage -import CoreImage.CIFilterBuiltins - -extension UIImage { - - static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { - let render = UIGraphicsImageRenderer(size: size) - - return render.image { (context: UIGraphicsImageRendererContext) in - context.cgContext.setFillColor(color.cgColor) - context.fill(CGRect(origin: .zero, size: size)) - } - } - -} - -// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage -extension UIImage { - @available(iOS 14.0, *) - var dominantColor: UIColor? { - guard let inputImage = CIImage(image: self) else { return nil } - - let filter = CIFilter.areaAverage() - filter.inputImage = inputImage - filter.extent = inputImage.extent - guard let outputImage = filter.outputImage else { return nil } - - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [.workingColorSpace: kCFNull]) - context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) - } -} - -extension UIImage { - func blur(radius: CGFloat) -> UIImage? { - guard let inputImage = CIImage(image: self) else { return nil } - let blurFilter = CIFilter.gaussianBlur() - blurFilter.inputImage = inputImage - blurFilter.radius = Float(radius) - guard let outputImage = blurFilter.outputImage else { return nil } - guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil } - let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) - return image - } -} diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index e821b676c..3c3c43400 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -5,31 +5,66 @@ // Created by sxiaojian on 2021/3/8. // +import CoreImage +import CoreImage.CIFilterBuiltins import UIKit extension UIImage { - class func imageWithColor(color: UIColor, size: CGSize=CGSize(width: 1, height: 1)) -> UIImage? { - UIGraphicsBeginImageContextWithOptions(size, false, 0) - color.setFill() - UIRectFill(CGRect(origin: CGPoint.zero, size: size)) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - return image - } - public func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { - let maxRadius = min(size.width, size.height) / 2 - let cornerRadius: CGFloat - if let radius = radius, radius > 0 && radius <= maxRadius { - cornerRadius = radius - } else { - cornerRadius = maxRadius + static func placeholder(size: CGSize = CGSize(width: 1, height: 1), color: UIColor) -> UIImage { + let render = UIGraphicsImageRenderer(size: size) + + return render.image { (context: UIGraphicsImageRendererContext) in + context.cgContext.setFillColor(color.cgColor) + context.fill(CGRect(origin: .zero, size: size)) } - UIGraphicsBeginImageContextWithOptions(size, false, scale) - let rect = CGRect(origin: .zero, size: size) - UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() - draw(in: rect) - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() + } +} + +// refs: https://www.hackingwithswift.com/example-code/media/how-to-read-the-average-color-of-a-uiimage-using-ciareaaverage +extension UIImage { + @available(iOS 14.0, *) + var dominantColor: UIColor? { + guard let inputImage = CIImage(image: self) else { return nil } + + let filter = CIFilter.areaAverage() + filter.inputImage = inputImage + filter.extent = inputImage.extent + guard let outputImage = filter.outputImage else { return nil } + + var bitmap = [UInt8](repeating: 0, count: 4) + let context = CIContext(options: [.workingColorSpace: kCFNull]) + context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) + + return UIColor(red: CGFloat(bitmap[0]) / 255, green: CGFloat(bitmap[1]) / 255, blue: CGFloat(bitmap[2]) / 255, alpha: CGFloat(bitmap[3]) / 255) + } +} + +extension UIImage { + func blur(radius: CGFloat) -> UIImage? { + guard let inputImage = CIImage(image: self) else { return nil } + let blurFilter = CIFilter.gaussianBlur() + blurFilter.inputImage = inputImage + blurFilter.radius = Float(radius) + guard let outputImage = blurFilter.outputImage else { return nil } + guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { return nil } + let image = UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) return image } } + +public extension UIImage { + func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { + let maxRadius = min(size.width, size.height) / 2 + let cornerRadius: CGFloat = { + guard let radius = radius, radius > 0 else { return maxRadius } + return min(radius, maxRadius) + }() + + let render = UIGraphicsImageRenderer(size: size) + return render.image { (_: UIGraphicsImageRendererContext) in + let rect = CGRect(origin: .zero, size: size) + UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius).addClip() + draw(in: rect) + } + } +} diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index e20f5cca0..980e5ae87 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -5,13 +5,11 @@ // Created by sxiaojian on 2021/3/8. // -import os.log import CoreDataStack +import os.log import UIKit - final class AudioContainerView: UIView { - static let cornerRadius: CGFloat = 22 let container: UIStackView = { @@ -20,7 +18,7 @@ final class AudioContainerView: UIView { stackView.distribution = .fill stackView.alignment = .center stackView.spacing = 11 - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 9, bottom: 0, right: 9) + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) stackView.isLayoutMarginsRelativeArrangement = true stackView.layer.cornerRadius = AudioContainerView.cornerRadius stackView.clipsToBounds = true @@ -29,7 +27,7 @@ final class AudioContainerView: UIView { return stackView }() - let checkmarkBackgroundView: UIView = { + let playButtonBackgroundView: UIView = { let view = UIView() view.layer.cornerRadius = 16 view.clipsToBounds = true @@ -39,7 +37,7 @@ final class AudioContainerView: UIView { }() let playButton: UIButton = { - let button = UIButton(type: .custom) + let button = HighlightDimmableButton(type: .custom) let image = UIImage(systemName: "play.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .bold))! button.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) @@ -57,7 +55,7 @@ final class AudioContainerView: UIView { slider.translatesAutoresizingMaskIntoConstraints = false slider.minimumTrackTintColor = Asset.Colors.Slider.bar.color slider.maximumTrackTintColor = Asset.Colors.Slider.bar.color - if let image = UIImage.imageWithColor(color: .white, size: CGSize(width: 22, height: 22))?.withRoundedCorners(radius: 11) { + if let image = UIImage.placeholder(size: CGSize(width: 22, height: 22), color: .white).withRoundedCorners(radius: 11) { slider.setThumbImage(image, for: .normal) } return slider @@ -81,13 +79,10 @@ final class AudioContainerView: UIView { super.init(coder: coder) _init() } - } extension AudioContainerView { - private func _init() { - addSubview(container) NSLayoutConstraint.activate([ container.topAnchor.constraint(equalTo: topAnchor), @@ -96,14 +91,14 @@ extension AudioContainerView { bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) - //checkmark - checkmarkBackgroundView.addSubview(playButton) - container.addArrangedSubview(checkmarkBackgroundView) + // checkmark + playButtonBackgroundView.addSubview(playButton) + container.addArrangedSubview(playButtonBackgroundView) NSLayoutConstraint.activate([ - playButton.centerXAnchor.constraint(equalTo: checkmarkBackgroundView.centerXAnchor), - playButton.centerYAnchor.constraint(equalTo: checkmarkBackgroundView.centerYAnchor), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: 32), - checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: 32), + playButton.centerXAnchor.constraint(equalTo: playButtonBackgroundView.centerXAnchor), + playButton.centerYAnchor.constraint(equalTo: playButtonBackgroundView.centerYAnchor), + playButtonBackgroundView.heightAnchor.constraint(equalToConstant: 32), + playButtonBackgroundView.widthAnchor.constraint(equalToConstant: 32), ]) container.addArrangedSubview(slider) @@ -113,5 +108,4 @@ extension AudioContainerView { timeLabel.widthAnchor.constraint(equalToConstant: 40), ]) } - } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 8d4e9e2a5..de250a539 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -5,8 +5,8 @@ // Created by sxiaojian on 2021/3/9. // -import Foundation import CoreDataStack +import Foundation import UIKit class AudioContainerViewModel { @@ -17,10 +17,11 @@ class AudioContainerViewModel { guard let duration = audioAttachment.meta?.original?.duration else { return } let audioView = cell.statusView.audioView audioView.timeLabel.text = duration.asString(style: .positional) - + audioView.playButton.publisher(for: .touchUpInside) - .sink { button in - if (button.isSelected) { + .sink { _ in + let isPlaying = AudioPlayer.shared.playbackState.value == .readyToPlay || AudioPlayer.shared.playbackState.value == .playing + if isPlaying { AudioPlayer.shared.pause() } else { if audioAttachment === AudioPlayer.shared.attachment { @@ -39,15 +40,15 @@ class AudioContainerViewModel { .sink { slider in let slider = slider as! UISlider let time = Double(slider.value) * duration - AudioPlayer.shared.seekToTime(time: time) } .store(in: &cell.disposeBag) - self.observePlayer(cell:cell, audioAttachment: audioAttachment) + self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { self.resetAudioView(audioView: audioView) } } + static func observePlayer( cell: StatusTableViewCell, audioAttachment: Attachment @@ -61,16 +62,17 @@ class AudioContainerViewModel { .sink(receiveValue: { time in audioView.timeLabel.text = time.asString(style: .positional) if let duration = audioAttachment.meta?.original?.duration, !audioView.slider.isTracking { - audioView.slider.setValue(Float(time/duration), animated: true) + audioView.slider.setValue(Float(time / duration), animated: true) } }) .store(in: &cell.disposeBag) AudioPlayer.shared.playbackState .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in - if (audioAttachment === AudioPlayer.shared.attachment) { + if audioAttachment === AudioPlayer.shared.attachment { let isPlaying = playbackState == .playing || playbackState == .readyToPlay audioView.playButton.isSelected = isPlaying + audioView.slider.isEnabled = isPlaying if playbackState == .stopped { self.resetAudioView(audioView: audioView) } @@ -80,8 +82,10 @@ class AudioContainerViewModel { }) .store(in: &cell.disposeBag) } - static func resetAudioView(audioView:AudioContainerView) { + + static func resetAudioView(audioView: AudioContainerView) { audioView.playButton.isSelected = false audioView.slider.setValue(0, animated: false) + audioView.slider.isEnabled = false } } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift index bb03ba1bd..d50d87695 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Attachment.swift @@ -23,7 +23,7 @@ extension Mastodon.Entity { public let id: ID public let type: Type public let url: String - public let previewURL: String? + public let previewURL: String? // could be nil when attachement is audio public let remoteURL: String? public let textURL: String? From 0ae89aff9fd449da2d6f48b1028c517a74985d16 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 16:54:05 +0800 Subject: [PATCH 058/400] fix: can't play audio again when stoped --- .../ViewModel/AudioContainerViewModel.swift | 5 ++--- Mastodon/Service/AudioPlayer.swift | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index de250a539..303eae2a7 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,8 +20,7 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - let isPlaying = AudioPlayer.shared.playbackState.value == .readyToPlay || AudioPlayer.shared.playbackState.value == .playing - if isPlaying { + if AudioPlayer.shared.isPlaying() { AudioPlayer.shared.pause() } else { if audioAttachment === AudioPlayer.shared.attachment { @@ -70,7 +69,7 @@ class AudioContainerViewModel { .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in if audioAttachment === AudioPlayer.shared.attachment { - let isPlaying = playbackState == .playing || playbackState == .readyToPlay + let isPlaying = AudioPlayer.shared.isPlaying() audioView.playButton.isSelected = isPlaying audioView.slider.isEnabled = isPlaying if playbackState == .stopped { diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 95be2e78a..15168c761 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -44,7 +44,11 @@ extension AudioPlayer { } if audioAttachment == attachment { + if self.playbackState.value == .stopped { + self.seekToTime(time: 0) + } player.play() + self.playbackState.value = .playing return } @@ -52,7 +56,7 @@ extension AudioPlayer { player.replaceCurrentItem(with: playerItem) attachment = audioAttachment player.play() - playbackState.send(PlaybackState.playing) + playbackState.value = .playing } func addObserver() { @@ -99,20 +103,23 @@ extension AudioPlayer { .store(in: &disposeBag) NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) .sink { _ in - self.playbackState.send(PlaybackState.stopped) + self.playbackState.value = .stopped + self.currentTimeSubject.value = 0 } .store(in: &disposeBag) } - + func isPlaying() -> Bool { + return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing + } func resume() { player.play() - playbackState.send(PlaybackState.playing) + playbackState.value = .playing } func pause() { player.pause() - playbackState.send(PlaybackState.paused) + playbackState.value = .paused } func seekToTime(time: TimeInterval) { From 2b02b8deb65cfd3e6e16c45fb611f8d4c558b3e7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 9 Mar 2021 17:13:17 +0800 Subject: [PATCH 059/400] fix: play audio between two toots --- .../ViewModel/AudioContainerViewModel.swift | 31 +++++++++++-------- Mastodon/Service/AudioPlayer.swift | 2 +- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 303eae2a7..510df76bc 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,18 +20,18 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - if AudioPlayer.shared.isPlaying() { - AudioPlayer.shared.pause() - } else { - if audioAttachment === AudioPlayer.shared.attachment { - if AudioPlayer.shared.currentTimeSubject.value == 0 { - AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) - } else { - AudioPlayer.shared.resume() - } + + if audioAttachment === AudioPlayer.shared.attachment { + if AudioPlayer.shared.isPlaying() { + AudioPlayer.shared.pause() } else { + AudioPlayer.shared.resume() + } + if AudioPlayer.shared.currentTimeSubject.value == 0 { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) } + } else { + AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) } } .store(in: &cell.disposeBag) @@ -44,7 +44,7 @@ class AudioContainerViewModel { .store(in: &cell.disposeBag) self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } } @@ -73,18 +73,23 @@ class AudioContainerViewModel { audioView.playButton.isSelected = isPlaying audioView.slider.isEnabled = isPlaying if playbackState == .stopped { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } } else { - self.resetAudioView(audioView: audioView) + self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) } }) .store(in: &cell.disposeBag) } - static func resetAudioView(audioView: AudioContainerView) { + static func resetAudioView( + audioView: AudioContainerView, + audioAttachment: Attachment + ) { audioView.playButton.isSelected = false audioView.slider.setValue(0, animated: false) audioView.slider.isEnabled = false + guard let duration = audioAttachment.meta?.original?.duration else { return } + audioView.timeLabel.text = duration.asString(style: .positional) } } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 15168c761..458ca7614 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -51,7 +51,7 @@ extension AudioPlayer { self.playbackState.value = .playing return } - + player.pause() let playerItem = AVPlayerItem(url: url) player.replaceCurrentItem(with: playerItem) attachment = audioAttachment From fd4e99907b2c1d11021e64d83f6008eb7fb8f982 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 18:54:21 +0800 Subject: [PATCH 060/400] fix: slider jumping after drag issue. Fix player can not play again issue --- .../ViewModel/AudioContainerViewModel.swift | 27 ++++++++++++------- Mastodon/Service/AudioPlayer.swift | 14 ++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 510df76bc..cb1603404 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -20,7 +20,6 @@ class AudioContainerViewModel { audioView.playButton.publisher(for: .touchUpInside) .sink { _ in - if audioAttachment === AudioPlayer.shared.attachment { if AudioPlayer.shared.isPlaying() { AudioPlayer.shared.pause() @@ -53,16 +52,26 @@ class AudioContainerViewModel { audioAttachment: Attachment ) { let audioView = cell.statusView.audioView + var lastCurrentTimeSubject: TimeInterval? AudioPlayer.shared.currentTimeSubject - .receive(on: DispatchQueue.main) - .filter { _ in - audioAttachment === AudioPlayer.shared.attachment - } - .sink(receiveValue: { time in - audioView.timeLabel.text = time.asString(style: .positional) - if let duration = audioAttachment.meta?.original?.duration, !audioView.slider.isTracking { - audioView.slider.setValue(Float(time / duration), animated: true) + .throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true) + .compactMap { time -> (TimeInterval, Float)? in + defer { + lastCurrentTimeSubject = time } + guard audioAttachment === AudioPlayer.shared.attachment else { return nil } + guard let duration = audioAttachment.meta?.original?.duration else { return nil } + + if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 { + guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce + } + + guard !audioView.slider.isTracking else { return nil } + return (time, Float(time / duration)) + } + .sink(receiveValue: { time, progress in + audioView.timeLabel.text = time.asString(style: .positional) + audioView.slider.setValue(progress, animated: true) }) .store(in: &cell.disposeBag) AudioPlayer.shared.playbackState diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 458ca7614..13479f0af 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -18,14 +18,16 @@ final class AudioPlayer: NSObject { var timeObserver: Any? var statusObserver: Any? var attachment: Attachment? - var currentURL: URL? + let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) + + // MARK: - singleton public static let shared = AudioPlayer() let currentTimeSubject = CurrentValueSubject(0) - override init() { + private override init() { super.init() addObserver() } @@ -45,7 +47,7 @@ extension AudioPlayer { if audioAttachment == attachment { if self.playbackState.value == .stopped { - self.seekToTime(time: 0) + self.seekToTime(time: .zero) } player.play() self.playbackState.value = .playing @@ -97,12 +99,14 @@ extension AudioPlayer { case .unknown: self.playbackState.value = .unknown @unknown default: - fatalError() + assertionFailure() } }) .store(in: &disposeBag) NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: nil) - .sink { _ in + .sink { [weak self] _ in + guard let self = self else { return } + self.player.seek(to: .zero) self.playbackState.value = .stopped self.currentTimeSubject.value = 0 } From defb0ae6e06753f82ff087253756fe919a9589aa Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:07:30 +0800 Subject: [PATCH 061/400] feat: make play button reflect with state change --- .../ViewModel/AudioContainerViewModel.swift | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index cb1603404..70509d40e 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -43,7 +43,7 @@ class AudioContainerViewModel { .store(in: &cell.disposeBag) self.observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } } @@ -78,26 +78,33 @@ class AudioContainerViewModel { .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in if audioAttachment === AudioPlayer.shared.attachment { - let isPlaying = AudioPlayer.shared.isPlaying() - audioView.playButton.isSelected = isPlaying - audioView.slider.isEnabled = isPlaying - if playbackState == .stopped { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) - } + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState) } else { - self.resetAudioView(audioView: audioView, audioAttachment: audioAttachment) + configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } }) .store(in: &cell.disposeBag) } - static func resetAudioView( + static func configureAudioView( audioView: AudioContainerView, - audioAttachment: Attachment + audioAttachment: Attachment, + playbackState: PlaybackState ) { - audioView.playButton.isSelected = false - audioView.slider.setValue(0, animated: false) - audioView.slider.isEnabled = false + switch playbackState { + case .stopped: + audioView.playButton.isSelected = false + audioView.slider.isEnabled = false + audioView.slider.setValue(0, animated: false) + case .paused: + audioView.playButton.isSelected = false + audioView.slider.isEnabled = true + case .playing, .readyToPlay: + audioView.playButton.isSelected = true + audioView.slider.isEnabled = true + default: + assertionFailure() + } guard let duration = audioAttachment.meta?.original?.duration else { return } audioView.timeLabel.text = duration.asString(style: .positional) } From 51b6455c3783cce7b17827dbc8dd1c07b1abdfa3 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:39:44 +0800 Subject: [PATCH 062/400] chore: rename reblog API --- .../Protocol/StatusProvider/StatusProviderFacade.swift | 6 +++--- Mastodon/Service/APIService/APIService+Reblog.swift | 6 +++--- ...+API+Status+Reblog.swift => Mastodon+API+Reblog.swift} | 8 ++++---- .../Sources/MastodonSDK/API/Mastodon+API+Status.swift | 4 +--- MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift | 1 + 5 files changed, 12 insertions(+), 13 deletions(-) rename MastodonSDK/Sources/MastodonSDK/API/{Mastodon+API+Status+Reblog.swift => Mastodon+API+Reblog.swift} (97%) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index cc5589978..9d1e33219 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -160,15 +160,15 @@ extension StatusProviderFacade { let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Status.Reblog.BoostKind)? in + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.BoostKind)? in guard let toot = toot?.reblog ?? toot else { return nil } - let boostKind: Mastodon.API.Status.Reblog.BoostKind = { + let boostKind: Mastodon.API.Reblog.BoostKind = { let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isBoosted ? .undoBoost : .boost }() return (toot.objectID, boostKind) } - .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Status.Reblog.BoostKind), Error> in + .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.BoostKind), Error> in return context.apiService.boost( tootObjectID: tootObjectID, mastodonUserObjectID: mastodonUserObjectID, diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index ca47ec713..796a7817a 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -18,7 +18,7 @@ extension APIService { func boost( tootObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, - boostKind: Mastodon.API.Status.Reblog.BoostKind + boostKind: Mastodon.API.Reblog.BoostKind ) -> AnyPublisher { var _targetTootID: Toot.ID? let managedObjectContext = backgroundManagedObjectContext @@ -51,13 +51,13 @@ extension APIService { // send boost request to remote func boost( statusID: Mastodon.Entity.Status.ID, - boostKind: Mastodon.API.Status.Reblog.BoostKind, + boostKind: Mastodon.API.Reblog.BoostKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Status.Reblog.boost( + return Mastodon.API.Reblog.boost( session: session, domain: domain, statusID: statusID, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift similarity index 97% rename from MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift rename to MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift index bc898de9a..862028d33 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status+Reblog.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -8,7 +8,7 @@ import Foundation import Combine -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblogged_by" @@ -52,7 +52,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func reblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblog" @@ -107,7 +107,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { static func unreblogEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/unreblog" @@ -151,7 +151,7 @@ extension Mastodon.API.Status.Reblog { } -extension Mastodon.API.Status.Reblog { +extension Mastodon.API.Reblog { public enum BoostKind { case boost diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift index fd3d96277..09dd07c4d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Status.swift @@ -7,6 +7,4 @@ import Foundation -extension Mastodon.API.Status { - public enum Reblog { } -} +extension Mastodon.API.Status { } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 8dbb2c3c7..8431287f8 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -95,6 +95,7 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } + public enum Reblog { } public enum Status { } public enum Timeline { } public enum Favorites { } From 81c22fee24dfdfb303579f10392f6e3fed7385e0 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 9 Mar 2021 19:41:18 +0800 Subject: [PATCH 063/400] fix: count not change after undo boost and undo favorite issue --- Mastodon/Service/APIService/APIService+Favorite.swift | 3 +++ Mastodon/Service/APIService/APIService+Reblog.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index af8f0ffa7..98b00cf18 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -95,6 +95,9 @@ extension APIService { return } APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + if favoriteKind == .destroy { + oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1))) + } os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 796a7817a..cfec5c7f4 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -101,6 +101,9 @@ extension APIService { return } APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + if boostKind == .undoBoost { + oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + } os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) From 1256ef1d8e3a2d43c6c64371790868a6316a467c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 13:36:01 +0800 Subject: [PATCH 064/400] feat: implement boost toot. Add stacked style avatar --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 14 +- .../Protocol/AvatarConfigurableView.swift | 13 +- ...meTimelineViewController+DebugAction.swift | 49 +++++ .../Container/AvatarStackContainerView.swift | 193 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 55 +++++ 6 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 081fb06ee..d6d3afb00 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -396,6 +397,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerView.swift; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1168,6 +1170,7 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1684,6 +1687,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 216d778ad..9d4fcad92 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -86,13 +86,23 @@ extension StatusSection { return L10n.Common.Controls.Status.userBoosted(name) }() - // set name username avatar + // set name username cell.statusView.nameLabel.text = { let author = (toot.reblog ?? toot).author return author.displayName.isEmpty ? author.username : author.displayName }() cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + // set avatar + if let reblog = toot.reblog { + cell.statusView.avatarButton.isHidden = true + cell.statusView.avatarStackedContainerButton.isHidden = false + cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) + cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + } else { + cell.statusView.avatarButton.isHidden = false + cell.statusView.avatarStackedContainerButton.isHidden = true + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + } // set text cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6c51d576c..6391066e1 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -23,7 +23,13 @@ extension AvatarConfigurableView { public func configure(with configuration: AvatarConfigurableViewConfiguration) { let placeholderImage: UIImage = { let placeholderImage = configuration.placeholderImage ?? UIImage.placeholder(size: Self.configurableAvatarImageSize, color: .systemFill) - return placeholderImage.af.imageRoundedIntoCircle() + if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { + return placeholderImage + .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) + .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) + } else { + return placeholderImage.af.imageRoundedIntoCircle() + } }() // cancel previous task @@ -65,7 +71,8 @@ extension AvatarConfigurableView { ) avatarImageView.layer.masksToBounds = true avatarImageView.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarImageView.layer.cornerCurve = .circular + avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( @@ -92,7 +99,7 @@ extension AvatarConfigurableView { ) avatarButton.layer.masksToBounds = true avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius - avatarButton.layer.cornerCurve = .continuous + avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0937e1fb4..9f2b4e720 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -45,10 +45,18 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToTopGapAction(action) }), + UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogToot(action) + }), UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.moveToFirstPollToot(action) }), + UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioToot(action) + }), // UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in // guard let self = self else { return } // self.moveToFirstReplyToot(action) @@ -101,6 +109,26 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstReblogToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.toot.reblog != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found reblog toot") + } + } + @objc private func moveToFirstPollToot(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() @@ -122,6 +150,27 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstAudioToot(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found audio toot") + } + } + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift new file mode 100644 index 000000000..f2f216059 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -0,0 +1,193 @@ +// +// AvatarStackContainerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import UIKit + +final class AvatarStackedImageView: UIImageView { } + +// MARK: - AvatarConfigurableView +extension AvatarStackedImageView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { CGSize(width: 28, height: 28) } + static var configurableAvatarImageCornerRadius: CGFloat { 4 } + var configurableAvatarImageView: UIImageView? { self } + var configurableAvatarButton: UIButton? { nil } +} + +import os.log +import UIKit + +extension UIControl.State: Hashable { } + +final class AvatarStackContainerButton: UIControl { + + static let containerSize = CGSize(width: 42, height: 42) + static let maskOffset: CGFloat = 2 + + // UIControl.Event - Application: 0x0F000000 + static let primaryAction = UIControl.Event(rawValue: 1 << 25) // 0x01000000 + var primaryActionState: UIControl.State = .normal + + let topLeadingAvatarStackedImageView = AvatarStackedImageView() + let bottomTrailingAvatarStackedImageView = AvatarStackedImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AvatarStackContainerButton { + + private func _init() { + topLeadingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topLeadingAvatarStackedImageView) + NSLayoutConstraint.activate([ + topLeadingAvatarStackedImageView.topAnchor.constraint(equalTo: topAnchor), + topLeadingAvatarStackedImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + topLeadingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + topLeadingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + bottomTrailingAvatarStackedImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomTrailingAvatarStackedImageView) + NSLayoutConstraint.activate([ + bottomTrailingAvatarStackedImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomTrailingAvatarStackedImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomTrailingAvatarStackedImageView.widthAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.width).priority(.defaultHigh), + bottomTrailingAvatarStackedImageView.heightAnchor.constraint(equalToConstant: AvatarStackedImageView.configurableAvatarImageSize.height).priority(.defaultHigh), + ]) + + // mask topLeadingAvatarStackedImageView + let offset: CGFloat = 2 + let path: CGPath = { + let path = CGMutablePath() + path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.containerSize)) + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: AvatarStackedImageView.configurableAvatarImageSize.width + offset, + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + } else { + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset, + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) + } + return path + }() + let maskShapeLayer = CAShapeLayer() + maskShapeLayer.backgroundColor = UIColor.black.cgColor + maskShapeLayer.fillRule = .evenOdd + maskShapeLayer.path = path + topLeadingAvatarStackedImageView.layer.mask = maskShapeLayer + + topLeadingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + bottomTrailingAvatarStackedImageView.image = UIImage.placeholder(color: .systemFill) + } + + override var intrinsicContentSize: CGSize { + return AvatarStackContainerButton.containerSize + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.beginTracking(touch, with: event) + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + defer { updateAppearance() } + + updateState(touch: touch, event: event) + return super.continueTracking(touch, with: event) + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + defer { updateAppearance() } + resetState() + + if let touch = touch { + if AvatarStackContainerButton.isTouching(touch, view: self, event: event) { + sendActions(for: AvatarStackContainerButton.primaryAction) + } else { + // do nothing + } + } + + super.endTracking(touch, with: event) + } + + override func cancelTracking(with event: UIEvent?) { + defer { updateAppearance() } + + resetState() + super.cancelTracking(with: event) + } + +} + +extension AvatarStackContainerButton { + + private func updateAppearance() { + topLeadingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + bottomTrailingAvatarStackedImageView.alpha = primaryActionState.contains(.highlighted) ? 0.6 : 1.0 + } + + private static func isTouching(_ touch: UITouch, view: UIView, event: UIEvent?) -> Bool { + let location = touch.location(in: view) + return view.point(inside: location, with: event) + } + + private func resetState() { + primaryActionState = .normal + } + + private func updateState(touch: UITouch, event: UIEvent?) { + primaryActionState = AvatarStackContainerButton.isTouching(touch, view: self, event: event) ? .highlighted : .normal + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AvatarStackContainerButton_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 42) { + let avatarStackContainerButton = AvatarStackContainerButton() + avatarStackContainerButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarStackContainerButton.widthAnchor.constraint(equalToConstant: 42), + avatarStackContainerButton.heightAnchor.constraint(equalToConstant: 42), + ]) + return avatarStackContainerButton + } + .previewLayout(.fixed(width: 42, height: 42)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647fe..5db0e11ee 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -60,6 +60,7 @@ final class StatusView: UIView { button.setImage(placeholderImage, for: .normal) return button }() + let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() let nameLabel: UILabel = { let label = UILabel() @@ -238,6 +239,14 @@ extension StatusView { avatarButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), avatarButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), ]) + avatarStackedContainerButton.translatesAutoresizingMaskIntoConstraints = false + avatarView.addSubview(avatarStackedContainerButton) + NSLayoutConstraint.activate([ + avatarStackedContainerButton.topAnchor.constraint(equalTo: avatarView.topAnchor), + avatarStackedContainerButton.leadingAnchor.constraint(equalTo: avatarView.leadingAnchor), + avatarStackedContainerButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor), + avatarStackedContainerButton.bottomAnchor.constraint(equalTo: avatarView.bottomAnchor), + ]) // author meta container: [title container | subtitle container] let authorMetaContainerStackView = UIStackView() @@ -360,6 +369,7 @@ extension StatusView { pollStatusStackView.isHidden = true audioView.isHidden = true + avatarStackedContainerButton.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false @@ -429,6 +439,7 @@ import SwiftUI struct StatusView_Previews: PreviewProvider { static let avatarFlora = UIImage(named: "tiraya-adam") + static let avatarMarkus = UIImage(named: "markus-spiske") static var previews: some View { Group { @@ -443,6 +454,49 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Normal") + UIViewPreview(width: 375) { + let statusView = StatusView() + statusView.headerContainerStackView.isHidden = false + statusView.avatarButton.isHidden = true + statusView.avatarStackedContainerButton.isHidden = false + statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarMarkus + ) + ) + return statusView + } + .previewLayout(.fixed(width: 375, height: 200)) + .previewDisplayName("Boost") + UIViewPreview(width: 375) { + let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) + statusView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: nil, + placeholderImage: avatarFlora + ) + ) + statusView.headerContainerStackView.isHidden = false + let images = MosaicImageView_Previews.images + let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, imageView) in imageViews.enumerated() { + imageView.image = images[i] + } + statusView.statusMosaicImageViewContainer.isHidden = false + statusView.statusMosaicImageViewContainer.blurVisualEffectView.isHidden = true + statusView.isStatusTextSensitive = false + return statusView + } + .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Image Meida") UIViewPreview(width: 375) { let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) statusView.configure( @@ -466,6 +520,7 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 380)) + .previewDisplayName("Content Sensitive") } } From cd112c5102ba1ba26ae8f8229e91bc45b6830932 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 14:06:08 +0800 Subject: [PATCH 065/400] fix: reblog avatar RTL support issue --- .../Container/AvatarStackContainerView.swift | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift index f2f216059..361f744bb 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -71,28 +71,17 @@ extension AvatarStackContainerButton { let offset: CGFloat = 2 let path: CGPath = { let path = CGMutablePath() - path.addRect(CGRect(origin: .zero, size: AvatarStackContainerButton.containerSize)) - if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { - path.addPath(UIBezierPath( - roundedRect: CGRect( - x: AvatarStackedImageView.configurableAvatarImageSize.width + offset, - y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, - width: AvatarStackedImageView.configurableAvatarImageSize.width, - height: AvatarStackedImageView.configurableAvatarImageSize.height - ), - cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius - ).cgPath) - } else { - path.addPath(UIBezierPath( - roundedRect: CGRect( - x: AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset, - y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, - width: AvatarStackedImageView.configurableAvatarImageSize.width, - height: AvatarStackedImageView.configurableAvatarImageSize.height - ), - cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius - ).cgPath) - } + path.addRect(CGRect(origin: .zero, size: AvatarStackedImageView.configurableAvatarImageSize)) + let mirrorScale: CGFloat = UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft ? -1 : 1 + path.addPath(UIBezierPath( + roundedRect: CGRect( + x: mirrorScale * (AvatarStackContainerButton.containerSize.width - AvatarStackedImageView.configurableAvatarImageSize.width - offset), + y: AvatarStackContainerButton.containerSize.height - AvatarStackedImageView.configurableAvatarImageSize.height - offset, + width: AvatarStackedImageView.configurableAvatarImageSize.width, + height: AvatarStackedImageView.configurableAvatarImageSize.height + ), + cornerRadius: AvatarStackedImageView.configurableAvatarImageCornerRadius + ).cgPath) return path }() let maskShapeLayer = CAShapeLayer() From a5f2bf2334687f766613b1b8b21b3f9d52d07e4c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 14:09:38 +0800 Subject: [PATCH 066/400] chore: code cleanup --- .../Container/AvatarStackContainerView.swift | 5 +-- .../APIService/APIService+Reblog.swift | 39 ------------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift index 361f744bb..ad828d9d2 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift @@ -5,8 +5,8 @@ // Created by MainasuK Cirno on 2021-3-10. // +import os.log import UIKit - final class AvatarStackedImageView: UIImageView { } // MARK: - AvatarConfigurableView @@ -17,9 +17,6 @@ extension AvatarStackedImageView: AvatarConfigurableView { var configurableAvatarButton: UIButton? { nil } } -import os.log -import UIKit - extension UIControl.State: Hashable { } final class AvatarStackContainerButton: UIControl { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index cfec5c7f4..55b4699ae 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -131,42 +131,3 @@ extension APIService { } } - -extension APIService { -// func likeList( -// limit: Int = onceRequestTootMaxCount, -// userID: String, -// maxID: String? = nil, -// mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox -// ) -> AnyPublisher, Error> { -// -// let requestMastodonUserID = mastodonAuthenticationBox.userID -// let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) -// return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) -// .map { response -> AnyPublisher, Error> in -// let log = OSLog.api -// -// return APIService.Persist.persistTimeline( -// managedObjectContext: self.backgroundManagedObjectContext, -// domain: mastodonAuthenticationBox.domain, -// query: query, -// response: response, -// persistType: .likeList, -// requestMastodonUserID: requestMastodonUserID, -// log: log -// ) -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .switchToLatest() -// .eraseToAnyPublisher() -// } -} From e1143b0ce475e26415a665cad3a37c410f259ba3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 10 Mar 2021 14:36:28 +0800 Subject: [PATCH 067/400] feature: video & gifv support --- Mastodon.xcodeproj/project.pbxproj | 26 ++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Diffiable/Section/StatusSection.swift | 54 ++++++- Mastodon/Extension/AVPlayer.swift | 22 +++ ...dency+AVPlayerViewControllerDelegate.swift | 21 +++ .../StatusProvider+UITableViewDelegate.swift | 12 ++ .../HomeTimelineViewController.swift | 18 ++- .../PublicTimelineViewController.swift | 18 ++- .../View/Container/MosaicPlayerView.swift | 121 ++++++++++++++ .../View/Container/TouchBlockingView.swift | 34 ++++ .../Scene/Share/View/Content/StatusView.swift | 8 +- .../TableviewCell/StatusTableViewCell.swift | 14 ++ .../ViewModel/VideoPlayerViewModel.swift | 152 ++++++++++++++++++ Mastodon/Service/ViedeoPlaybackService.swift | 126 +++++++++++++++ Mastodon/State/AppContext.swift | 2 + 15 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Extension/AVPlayer.swift create mode 100644 Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift create mode 100644 Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift create mode 100644 Mastodon/Scene/Share/View/Container/TouchBlockingView.swift create mode 100644 Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift create mode 100644 Mastodon/Service/ViedeoPlaybackService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f2db77a66..69f431909 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -89,8 +89,13 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; + 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */; }; + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; @@ -329,6 +334,12 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; + 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicPlayerView.swift; sourceTree = ""; }; + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -459,7 +470,6 @@ 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, - 45B49097460EDE530AD5AA72 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -651,6 +661,7 @@ DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, ); path = Service; sourceTree = ""; @@ -673,6 +684,7 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */, 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, + 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, ); path = Protocol; sourceTree = ""; @@ -1113,6 +1125,7 @@ 2D206B7F25F5F45E00143C56 /* UIImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, + 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, ); path = Extension; sourceTree = ""; @@ -1165,6 +1178,8 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, + 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */, + 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; sourceTree = ""; @@ -1174,6 +1189,7 @@ children = ( DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */, 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */, + 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -1551,11 +1567,13 @@ buildActionMask = 2147483647; files = ( DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, + 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, @@ -1603,6 +1621,7 @@ 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, + 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, @@ -1617,6 +1636,7 @@ DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, + 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, @@ -1662,6 +1682,7 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, + 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, @@ -1686,6 +1707,7 @@ 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, + 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 543a09da9..3183f10d5 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" + "revision": "81dd1ce8401137637663046c7314e7c885bcc56d", + "version": "6.1.1" } }, { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 489b9d3b8..e27d64314 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -34,7 +34,11 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute + ) } cell.delegate = statusTableViewCellDelegate return cell @@ -45,7 +49,11 @@ extension StatusSection { // configure cell managedObjectContext.performAndWait { let toot = managedObjectContext.object(with: objectID) as! Toot - StatusSection.configure(cell: cell, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute + ) } cell.delegate = statusTableViewCellDelegate return cell @@ -69,9 +77,9 @@ extension StatusSection { } extension StatusSection { - static func configure( cell: StatusTableViewCell, + dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, toot: Toot, @@ -165,6 +173,39 @@ extension StatusSection { cell.statusView.audioView.isHidden = true } + // set GIF & video + let playerViewMaxSize: CGSize = { + let maxWidth: CGFloat = { + // use statusView width as container width + // that width follows readable width and keep constant width after rotate + let containerFrame = readableLayoutFrame ?? cell.statusView.frame + return containerFrame.width + }() + let scale: CGFloat = 1.3 + return CGSize(width: maxWidth, height: maxWidth * scale) + }() + + if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, + let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) + { + let parent = cell.delegate?.parent() + let mosaicPlayerView = cell.statusView.mosaicPlayerView + let playerViewController = mosaicPlayerView.setupPlayer( + aspectRatio: videoPlayerViewModel.videoSize, + maxSize: playerViewMaxSize, + parent: parent + ) + playerViewController.delegate = cell.delegate?.playerViewControllerDelegate + playerViewController.player = videoPlayerViewModel.player + playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif + + mosaicPlayerView.gifIndicatorLabel.isHidden = videoPlayerViewModel.videoKind != .gif + mosaicPlayerView.isHidden = false + + } else { + cell.statusView.mosaicPlayerView.playerViewController.player?.pause() + cell.statusView.mosaicPlayerView.playerViewController.player = nil + } // set poll let poll = (toot.reblog ?? toot).poll StatusSection.configure( @@ -244,7 +285,8 @@ extension StatusSection { timestampUpdatePublisher: AnyPublisher ) { guard let poll = poll, - let managedObjectContext = poll.managedObjectContext else { + let managedObjectContext = poll.managedObjectContext + else { cell.statusView.pollTableView.isHidden = true cell.statusView.pollStatusStackView.isHidden = true cell.statusView.pollVoteButton.isHidden = true @@ -288,10 +330,10 @@ extension StatusSection { cell.statusView.pollTableView.allowsSelection = !poll.expired let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + (option.votedBy ?? Set()).map(\.id).contains(requestUserID) } let didVotedLocal = !votedOptions.isEmpty - let didVotedRemote = (poll.votedBy ?? Set()).map { $0.id }.contains(requestUserID) + let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) cell.statusView.pollVoteButton.isEnabled = didVotedLocal cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) diff --git a/Mastodon/Extension/AVPlayer.swift b/Mastodon/Extension/AVPlayer.swift new file mode 100644 index 000000000..3e9c06cc2 --- /dev/null +++ b/Mastodon/Extension/AVPlayer.swift @@ -0,0 +1,22 @@ +// +// AVPlayer.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit + +// MARK: - CustomDebugStringConvertible +extension AVPlayer.TimeControlStatus: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .paused: return "paused" + case .waitingToPlayAtSpecifiedRate: return "waitingToPlayAtSpecifiedRate" + case .playing: return "playing" + @unknown default: + assertionFailure() + return "" + } + } +} diff --git a/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift new file mode 100644 index 000000000..e52fdc059 --- /dev/null +++ b/Mastodon/Protocol/NeedsDependency+AVPlayerViewControllerDelegate.swift @@ -0,0 +1,21 @@ +// +// NeedsDependency+AVPlayerViewControllerDelegate.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import Foundation +import AVKit + +extension NeedsDependency where Self: AVPlayerViewControllerDelegate { + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = true + } + + func handlePlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + context.videoPlaybackService.playerViewModel(for: playerViewController)?.isFullScreenPresentationing = false + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 93f627c09..3411919c1 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -66,6 +66,18 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", ((#file as NSString).lastPathComponent), #line, #function, poll.id) }) .store(in: &disposeBag) + + toot(for: cell, indexPath: indexPath) + .sink { [weak self] toot in + guard let self = self else { return } + guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } + + DispatchQueue.main.async { + videoPlayerViewModel.willDisplay() + } + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b9d0f94e1..c9498dbea 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -337,5 +337,21 @@ extension HomeTimelineViewController: ScrollViewContainer { } +// MARK: - AVPlayerViewControllerDelegate +extension HomeTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension HomeTimelineViewController: StatusTableViewCellDelegate { } +extension HomeTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 98d2dbd94..50d36d296 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -204,5 +204,21 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat } } +// MARK: - AVPlayerViewControllerDelegate +extension PublicTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + // MARK: - StatusTableViewCellDelegate -extension PublicTimelineViewController: StatusTableViewCellDelegate { } +extension PublicTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift new file mode 100644 index 000000000..e7c478cea --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift @@ -0,0 +1,121 @@ +// +// MosaicPlayerView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import AVKit +import UIKit + +final class MosaicPlayerView: UIView { + static let cornerRadius: CGFloat = 8 + + private let container = UIView() + private let touchBlockingView = TouchBlockingView() + private var containerHeightLayoutConstraint: NSLayoutConstraint! + + let playerViewController = AVPlayerViewController() + + let gifIndicatorLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 16, weight: .heavy) + label.text = "GIF" + label.textColor = .white + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension MosaicPlayerView { + private func _init() { + container.translatesAutoresizingMaskIntoConstraints = false + addSubview(container) + containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) + NSLayoutConstraint.activate([ + container.topAnchor.constraint(equalTo: topAnchor), + container.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: container.trailingAnchor), + bottomAnchor.constraint(equalTo: container.bottomAnchor), + containerHeightLayoutConstraint, + ]) + + addSubview(gifIndicatorLabel) + gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + gifIndicatorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 4), + gifIndicatorLabel.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + + // will not influence full-screen playback + playerViewController.view.layer.masksToBounds = true + playerViewController.view.layer.cornerRadius = MosaicPlayerView.cornerRadius + playerViewController.view.layer.cornerCurve = .continuous + } +} + +extension MosaicPlayerView { + func reset() { + // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing + + gifIndicatorLabel.removeFromSuperview() + + playerViewController.willMove(toParent: nil) + playerViewController.view.removeFromSuperview() + playerViewController.removeFromParent() + + container.subviews.forEach { subview in + subview.removeFromSuperview() + } + } + + func setupPlayer(aspectRatio: CGSize, maxSize: CGSize, parent: UIViewController?) -> AVPlayerViewController { + reset() + + touchBlockingView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(touchBlockingView) + NSLayoutConstraint.activate([ + touchBlockingView.topAnchor.constraint(equalTo: container.topAnchor), + touchBlockingView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + touchBlockingView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + + let rect = AVMakeRect( + aspectRatio: aspectRatio, + insideRect: CGRect(origin: .zero, size: maxSize) + ) + + parent?.addChild(playerViewController) + playerViewController.view.translatesAutoresizingMaskIntoConstraints = false + touchBlockingView.addSubview(playerViewController.view) + parent.flatMap { playerViewController.didMove(toParent: $0) } + NSLayoutConstraint.activate([ + playerViewController.view.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), + playerViewController.view.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), + playerViewController.view.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), + playerViewController.view.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor), + touchBlockingView.widthAnchor.constraint(equalToConstant: floor(rect.width)).priority(.required - 1), + ]) + containerHeightLayoutConstraint.constant = floor(rect.height) + containerHeightLayoutConstraint.isActive = true + + gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false + touchBlockingView.addSubview(gifIndicatorLabel) + NSLayoutConstraint.activate([ + touchBlockingView.trailingAnchor.constraint(equalTo: gifIndicatorLabel.trailingAnchor, constant: 8), + touchBlockingView.bottomAnchor.constraint(equalTo: gifIndicatorLabel.bottomAnchor, constant: 8), + ]) + + return playerViewController + } +} diff --git a/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift new file mode 100644 index 000000000..b86137f1c --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/TouchBlockingView.swift @@ -0,0 +1,34 @@ +// +// TouchBlockingView.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import UIKit + +final class TouchBlockingView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TouchBlockingView { + + private func _init() { + isUserInteractionEnabled = true + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + // Blocking responder chain by not call super + // The subviews in this view will received touch event but superview not + } +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647fe..ad7734780 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -156,6 +156,8 @@ final class StatusView: UIView { return imageView }() + let mosaicPlayerView = MosaicPlayerView() + let audioView: AudioContainerView = { let audioView = AudioContainerView() return audioView @@ -342,6 +344,7 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + // audio audioView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(audioView) NSLayoutConstraint.activate([ @@ -349,6 +352,8 @@ extension StatusView { audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), audioView.heightAnchor.constraint(equalToConstant: 44) ]) + // video gif + statusContainerStackView.addArrangedSubview(mosaicPlayerView) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -359,7 +364,8 @@ extension StatusView { pollTableView.isHidden = true pollStatusStackView.isHidden = true audioView.isHidden = true - + mosaicPlayerView.isHidden = true + contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 13c3afba4..2f4000b95 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -16,6 +16,11 @@ protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } + func parent() -> UIViewController + var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -25,6 +30,13 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) } +extension StatusTableViewCellDelegate { + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + playerViewController.showsPlaybackControls.toggle() + } +} + final class StatusTableViewCell: UITableViewCell { static let bottomPaddingHeight: CGFloat = 10 @@ -42,6 +54,8 @@ final class StatusTableViewCell: UITableViewCell { statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() statusView.pollTableView.dataSource = nil + statusView.mosaicPlayerView.reset() + statusView.mosaicPlayerView.isHidden = true disposeBag.removeAll() observations.removeAll() } diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift new file mode 100644 index 000000000..7d2f68091 --- /dev/null +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -0,0 +1,152 @@ +// +// VideoPlayerViewModel.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import os.log +import UIKit +import AVKit +import CoreDataStack +import Combine + +final class VideoPlayerViewModel { + + var disposeBag = Set() + + // input + let previewImageURL: URL? + let videoURL: URL + let videoSize: CGSize + let videoKind: Kind + + var isTransitioning = false + var isFullScreenPresentationing = false + var isPlayingWhenEndDisplaying = false + + // prevent player state flick when tableView reload + private typealias Play = Bool + private let debouncePlayingState = PassthroughSubject() + + private var updateDate = Date() + + // output + let player: AVPlayer + private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) + + private var timeControlStatusObservation: NSKeyValueObservation? + let timeControlStatus = CurrentValueSubject(.paused) + + init(previewImageURL: URL?, videoURL: URL, videoSize: CGSize, videoKind: VideoPlayerViewModel.Kind) { + self.previewImageURL = previewImageURL + self.videoURL = videoURL + self.videoSize = videoSize + self.videoKind = videoKind + + let playerItem = AVPlayerItem(url: videoURL) + let player = videoKind == .gif ? AVQueuePlayer(playerItem: playerItem) : AVPlayer(playerItem: playerItem) + player.isMuted = true + self.player = player + + if videoKind == .gif { + setupLooper() + } + + timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", ((#file as NSString).lastPathComponent), #line, #function, player.timeControlStatus.debugDescription) + self.timeControlStatus.value = player.timeControlStatus + } + + // update audio session category for user interactive event stream + timeControlStatus + .sink { [weak self] timeControlStatus in + guard let _ = self else { return } + guard timeControlStatus == .playing else { return } + switch videoKind { + case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + try? AVAudioSession.sharedInstance().setActive(true) + } + .store(in: &disposeBag) + + debouncePlayingState + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { [weak self] isPlay in + guard let self = self else { return } + isPlay ? self.play() : self.pause() + } + .store(in: &disposeBag) + } + + deinit { + timeControlStatusObservation = nil + } + +} + +extension VideoPlayerViewModel { + enum Kind { + case gif + case video + } +} + +extension VideoPlayerViewModel { + + func setupLooper() { + guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } + guard let templateItem = queuePlayer.items().first else { return } + looper = AVPlayerLooper(player: queuePlayer, templateItem: templateItem) + } + + func play() { + switch videoKind { + case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) + case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + } + try? AVAudioSession.sharedInstance().setActive(true) + player.play() + updateDate = Date() + } + + func pause() { + player.pause() + updateDate = Date() + } + + func willDisplay() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + + switch videoKind { + case .gif: + play() // always auto play GIF + case .video: + guard isPlayingWhenEndDisplaying else { return } + // mute before resume + if updateDate.timeIntervalSinceNow < -3 { + player.isMuted = true + } + debouncePlayingState.send(true) + } + + updateDate = Date() + } + + func didEndDisplaying() { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + + isPlayingWhenEndDisplaying = timeControlStatus.value != .paused + switch videoKind { + case .gif: + pause() // always pause GIF immediately + case .video: + debouncePlayingState.send(false) + } + + updateDate = Date() + } + +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift new file mode 100644 index 000000000..5f1f2a121 --- /dev/null +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -0,0 +1,126 @@ +// +// ViedeoPlaybackService.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/10. +// + +import os.log +import Foundation +import AVKit +import Combine +import CoreDataStack + +final class VideoPlaybackService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] + + // only for video kind + weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? + +} + +extension VideoPlaybackService { + private func playerViewModel(_ playerViewModel: VideoPlayerViewModel, didUpdateTimeControlStatus: AVPlayer.TimeControlStatus) { + switch playerViewModel.videoKind { + case .gif: + // do nothing + return + case .video: + if playerViewModel.timeControlStatus.value != .paused { + latestPlayingVideoPlayerViewModel = playerViewModel + + // pause other player + for viewModel in viewPlayerViewModelDict.values { + guard viewModel.timeControlStatus.value != .paused else { continue } + guard viewModel !== playerViewModel else { continue } + viewModel.pause() + } + } else { + if latestPlayingVideoPlayerViewModel === playerViewModel { + latestPlayingVideoPlayerViewModel = nil + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + try? AVAudioSession.sharedInstance().setActive(false) + } + } + } + } +} + +extension VideoPlaybackService { + + func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { + // Core Data entity not thread-safe. Save attribute before enter working queue + guard let height = media.meta?.original?.height, + let width = media.meta?.original?.width, + let url = URL(string: media.url), + media.type == .gifv || media.type == .video else + { return nil } + + let previewImageURL = media.previewURL.flatMap({ URL(string: $0) }) + let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video + + var _viewModel: VideoPlayerViewModel? + workingQueue.sync { + if let viewModel = viewPlayerViewModelDict[url] { + _viewModel = viewModel + } else { + let viewModel = VideoPlayerViewModel( + previewImageURL: previewImageURL, + videoURL: url, + videoSize: CGSize(width: width, height: height), + videoKind: videoKind + ) + viewPlayerViewModelDict[url] = viewModel + setupListener(for: viewModel) + _viewModel = viewModel + } + } + return _viewModel + } + + func playerViewModel(for playerViewController: AVPlayerViewController) -> VideoPlayerViewModel? { + guard let url = (playerViewController.player?.currentItem?.asset as? AVURLAsset)?.url else { return nil } + return viewPlayerViewModelDict[url] + } + + private func setupListener(for viewModel: VideoPlayerViewModel) { + viewModel.timeControlStatus + .sink { [weak self] timeControlStatus in + guard let self = self else { return } + self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus) + } + .store(in: &disposeBag) + } + +} + +extension VideoPlaybackService { + func markTransitioning(for toot: Toot) { + guard let videoAttachment = toot.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } + guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } + videoPlayerViewModel.isTransitioning = true + } + + func viewDidDisappear(from viewController: UIViewController?) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + // note: do not retain view controller + // pause all player when view disppear exclude full screen player and other transitioning scene + for viewModel in viewPlayerViewModelDict.values { + guard !viewModel.isTransitioning else { + viewModel.isTransitioning = false + continue + } + guard !viewModel.isFullScreenPresentationing else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", ((#file as NSString).lastPathComponent), #line, #function) + continue + } + guard viewModel.videoKind == .video else { continue } + viewModel.pause() + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 08918496b..30069ec30 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -27,6 +27,8 @@ class AppContext: ObservableObject { let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! + let videoPlaybackService = VideoPlaybackService() + let overrideTraitCollection = CurrentValueSubject(nil) init() { From 807dfd9ea7b26eb811915b770d04a99bbfd18b41 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 16:38:14 +0800 Subject: [PATCH 068/400] feat: profile persist logic. Add replyTo and replyFrom relationship for Toot --- .../CoreData.xcdatamodel/contents | 6 +- CoreDataStack/Entity/Toot.swift | 3 + Mastodon.xcodeproj/project.pbxproj | 16 +- .../APIService/APIService+Account.swift | 2 + .../APIService/APIService+Favorite.swift | 2 +- .../APIService/APIService+HomeTimeline.swift | 2 +- .../APIService+PublicTimeline.swift | 2 +- .../APIService+CoreData+MastodonUser.swift | 25 +- .../CoreData/APIService+CoreData+Toot.swift | 55 ++- .../APIService+Persist+PersistCache.swift | 66 +++ .../APIService+Persist+PersistMemo.swift | 226 +++++++++ .../Persist/APIService+Persist+Timeline.swift | 446 ------------------ .../Persist/APIService+Persist+Toot.swift | 251 ++++++++++ 13 files changed, 624 insertions(+), 478 deletions(-) create mode 100644 Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift create mode 100644 Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift delete mode 100644 Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift create mode 100644 Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index be40ac57d..c9ebac45f 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -159,6 +159,8 @@ + + @@ -173,6 +175,6 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index c5fcf4869..3a2c91775 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -39,6 +39,7 @@ public final class Toot: NSManagedObject { // many-to-one relastionship @NSManaged public private(set) var author: MastodonUser @NSManaged public private(set) var reblog: Toot? + @NSManaged public private(set) var replyTo: Toot? // many-to-many relastionship @NSManaged public private(set) var favouritedBy: Set? @@ -57,6 +58,7 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? + @NSManaged public private(set) var replyFrom: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? @@ -70,6 +72,7 @@ public extension Toot { author: MastodonUser, reblog: Toot?, application: Application?, + replyTo: Toot?, poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f2db77a66..5528d0234 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -56,7 +56,7 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */; }; + 2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; @@ -149,6 +149,8 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; + DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -293,7 +295,7 @@ 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; + 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Toot.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; @@ -395,6 +397,8 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; + DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -658,7 +662,9 @@ 2D61335625C1887F00CAE157 /* Persist */ = { isa = PBXGroup; children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */, + 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */, + DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, + DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, ); path = Persist; sourceTree = ""; @@ -1578,7 +1584,7 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, - 2D61335825C188A000CAE157 /* APIService+Persist+Timeline.swift in Sources */, + 2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, @@ -1605,6 +1611,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, @@ -1654,6 +1661,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, + DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 2218bfa50..d8ea5cf4f 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -31,6 +31,7 @@ extension APIService { for: nil, in: domain, entity: account, + userCache: nil, networkDate: response.networkDate, log: log) let flag = isCreated ? "+" : "-" @@ -64,6 +65,7 @@ extension APIService { for: nil, in: domain, entity: account, + userCache: nil, networkDate: response.networkDate, log: log) let flag = isCreated ? "+" : "-" diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index e1d5febe7..3eee6b6e1 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -136,7 +136,7 @@ extension APIService { .map { response -> AnyPublisher, Error> in let log = OSLog.api - return APIService.Persist.persistTimeline( + return APIService.Persist.persistToots( managedObjectContext: self.backgroundManagedObjectContext, domain: mastodonAuthenticationBox.domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 0112a9da7..125c05208 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -40,7 +40,7 @@ extension APIService { authorization: authorization ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistToots( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index bfa3bb26e..288d1fd2e 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -39,7 +39,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistTimeline( + return APIService.Persist.persistToots( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 4f35a54c1..512e224d2 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -18,6 +18,7 @@ extension APIService.CoreData { for requestMastodonUser: MastodonUser?, in domain: String, entity: Mastodon.Entity.Account, + userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> (user: MastodonUser, isCreated: Bool) { @@ -29,15 +30,19 @@ extension APIService.CoreData { // fetch old mastodon user let oldMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil + if let userCache = userCache { + return userCache.dictionary[entity.id] + } else { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } } }() @@ -57,7 +62,7 @@ extension APIService.CoreData { into: managedObjectContext, property: mastodonUserProperty ) - + userCache?.dictionary[entity.id] = mastodonUser os_signpost(.event, log: log, name: "update database - process entity: createOrMergeMastodonUser", signpostID: processEntityTaskSignpostID, "did insert new mastodon user %{public}s: name %s", mastodonUser.identifier, mastodonUser.username) return (mastodonUser, true) } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift index 79fad947e..a4db46a8c 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift @@ -16,29 +16,49 @@ extension APIService.CoreData { static func createOrMergeToot( into managedObjectContext: NSManagedObjectContext, for requestMastodonUser: MastodonUser?, - entity: Mastodon.Entity.Status, domain: String, + entity: Mastodon.Entity.Status, + tootCache: APIService.Persist.PersistCache?, + userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) { - + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + } + // build tree let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) + let (toot, _, _) = createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + domain: domain, + entity: entity, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) return toot } // fetch old Toot let oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: entity.id) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil + if let tootCache = tootCache { + return tootCache.dictionary[entity.id] + } else { + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } } }() @@ -47,10 +67,16 @@ extension APIService.CoreData { APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) return (oldToot, false, false) } else { - let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, networkDate: networkDate, log: log) + let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log) let application = entity.application.flatMap { app -> Application? in Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) } + let replyTo: Toot? = { + // could be nil if target replyTo toot's persist task in the queue + guard let inReplyToID = entity.inReplyToID, + let replyTo = tootCache?.dictionary[inReplyToID] else { return nil } + return replyTo + }() let poll = entity.poll.flatMap { poll -> Poll in let options = poll.options.enumerated().map { i, option -> PollOption in let votedBy: MastodonUser? = (poll.ownVotes ?? []).contains(i) ? requestMastodonUser : nil @@ -92,6 +118,7 @@ extension APIService.CoreData { author: mastodonUser, reblog: reblog, application: application, + replyTo: replyTo, poll: poll, mentions: metions, emojis: emojis, @@ -103,6 +130,8 @@ extension APIService.CoreData { bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil, pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil ) + tootCache?.dictionary[entity.id] = toot + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) return (toot, true, isMastodonUserCreated) } } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift new file mode 100644 index 000000000..16461494a --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift @@ -0,0 +1,66 @@ +// +// APIService+Persist+PersistCache.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + class PersistCache { + var dictionary: [String : T] = [:] + } + +} + +extension APIService.Persist.PersistCache where T == Toot { + + static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + var value = Set() + for toot in toots { + value = value.union(ids(for: toot)) + } + return value + } + + static func ids(for toot: Mastodon.Entity.Status) -> Set { + var value = Set() + value.insert(toot.id) + if let inReplyToID = toot.inReplyToID { + value.insert(inReplyToID) + } + if let reblog = toot.reblog { + value = value.union(ids(for: reblog)) + } + return value + } + +} + +extension APIService.Persist.PersistCache where T == MastodonUser { + + static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + var value = Set() + for toot in toots { + value = value.union(ids(for: toot)) + } + return value + } + + static func ids(for toot: Mastodon.Entity.Status) -> Set { + var value = Set() + value.insert(toot.account.id) + if let inReplyToAccountID = toot.inReplyToAccountID { + value.insert(inReplyToAccountID) + } + if let reblog = toot.reblog { + value = value.union(ids(for: reblog)) + } + return value + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift new file mode 100644 index 000000000..08696ec55 --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -0,0 +1,226 @@ +// +// APIService+Persist+PersistMemo.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + class PersistMemo { + + let status: T + let children: [PersistMemo] + let memoType: MemoType + let statusProcessType: ProcessType + let authorProcessType: ProcessType + + enum MemoType { + case homeTimeline + case mentionTimeline + case userTimeline + case publicTimeline + case likeList + case searchList + case lookUp + + case reblog + + var flag: String { + switch self { + case .homeTimeline: return "H" + case .mentionTimeline: return "M" + case .userTimeline: return "U" + case .publicTimeline: return "P" + case .likeList: return "L" + case .searchList: return "S" + case .lookUp: return "LU" + case .reblog: return "R" + } + } + } + + enum ProcessType { + case create + case merge + + var flag: String { + switch self { + case .create: return "+" + case .merge: return "~" + } + } + } + + init( + status: T, + children: [PersistMemo], + memoType: MemoType, + statusProcessType: ProcessType, + authorProcessType: ProcessType + ) { + self.status = status + self.children = children + self.memoType = memoType + self.statusProcessType = statusProcessType + self.authorProcessType = authorProcessType + } + + } + +} + +extension APIService.Persist.PersistMemo { + + struct Counting { + var status = Counter() + var user = Counter() + + static func + (left: Counting, right: Counting) -> Counting { + return Counting( + status: left.status + right.status, + user: left.user + right.user + ) + } + + struct Counter { + var create = 0 + var merge = 0 + + static func + (left: Counter, right: Counter) -> Counter { + return Counter( + create: left.create + right.create, + merge: left.merge + right.merge + ) + } + } + } + + func count() -> Counting { + var counting = Counting() + + switch statusProcessType { + case .create: counting.status.create += 1 + case .merge: counting.status.merge += 1 + } + + switch authorProcessType { + case .create: counting.user.create += 1 + case .merge: counting.user.merge += 1 + } + + for child in children { + let childCounting = child.count() + counting = counting + childCounting + } + + return counting + } + +} + +extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { + + static func createOrMergeToot( + into managedObjectContext: NSManagedObjectContext, + for requestMastodonUser: MastodonUser?, + requestMastodonUserID: MastodonUser.ID?, + domain: String, + entity: Mastodon.Entity.Status, + memoType: MemoType, + tootCache: APIService.Persist.PersistCache?, + userCache: APIService.Persist.PersistCache?, + networkDate: Date, + log: OSLog + ) -> APIService.Persist.PersistMemo { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) + } + + // build tree + let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo in + createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + requestMastodonUserID: requestMastodonUserID, + domain: domain, + entity: entity, + memoType: .reblog, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) + } + let children = [reblogMemo].compactMap { $0 } + + + let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + domain: domain, + entity: entity, + tootCache: tootCache, + userCache: userCache, + networkDate: networkDate, + log: log + ) + let memo = APIService.Persist.PersistMemo( + status: toot, + children: children, + memoType: memoType, + statusProcessType: isTootCreated ? .create : .merge, + authorProcessType: isMastodonUserCreated ? .create : .merge + ) + + switch (memo.statusProcessType, memoType) { + case (.create, .homeTimeline), (.merge, .homeTimeline): + let timelineIndex = toot.homeTimelineIndexes? + .first { $0.userID == requestMastodonUserID } + guard let requestMastodonUserID = requestMastodonUserID else { + assertionFailure() + break + } + if timelineIndex == nil { + // make it indexed + let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) + let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot) + } else { + // enity already in home timeline + } + case (.create, .mentionTimeline), (.merge, .mentionTimeline): + break + // TODO: + default: + break + } + + return memo + } + + func log(indentLevel: Int = 0) -> String { + let indent = Array(repeating: " ", count: indentLevel).joined() + let preview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") + let message = "\(indent)[\(statusProcessType.flag)\(memoType.flag)](\(status.id)) [\(authorProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(preview)" + + var childrenMessages: [String] = [] + for child in children { + childrenMessages.append(child.log(indentLevel: indentLevel + 1)) + } + let result = [[message] + childrenMessages] + .flatMap { $0 } + .joined(separator: "\n") + + return result + } + +} + diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift deleted file mode 100644 index 460cab023..000000000 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Timeline.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// APIService+Persist+Timeline.swift -// Mastodon -// -// Created by sxiaojian on 2021/1/27. -// - -import os.log -import func QuartzCore.CACurrentMediaTime -import Foundation -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -extension APIService.Persist { - - enum PersistTimelineType { - case `public` - case home - case likeList - } - - static func persistTimeline( - managedObjectContext: NSManagedObjectContext, - domain: String, - query: Mastodon.API.Timeline.TimelineQuery, - response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, - persistType: PersistTimelineType, - requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint - log: OSLog - ) -> AnyPublisher, Never> { - let toots = response.value - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) - - return managedObjectContext.performChanges { - let contextTaskSignpostID = OSSignpostID(log: log) - let start = CACurrentMediaTime() - os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID) - defer { - os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID) - let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) - } - - // load request mastodon user - let requestMastodonUser: MastodonUser? = { - guard let requestMastodonUserID = requestMastodonUserID else { return nil } - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - // load working set into context to avoid cache miss - let cacheTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID) - let workingIDRecord = APIService.Persist.WorkingIDRecord.workingID(entities: toots) - - // contains toots and reblogs - let _tootCache: [Toot] = { - let request = Toot.sortedFetchRequest - let idSet = workingIDRecord.statusIDSet - .union(workingIDRecord.reblogIDSet) - let ids = Array(idSet) - request.predicate = Toot.predicate(domain: domain, ids: ids) - request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] - do { - return try managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return [] - } - }() - os_signpost(.event, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", _tootCache.count) - os_signpost(.end, log: log, name: "load toots into cache", signpostID: cacheTaskSignpostID) - - // remote timeline merge local timeline record set - // declare it before do working - let mergedOldTootsInTimeline = _tootCache.filter { - return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false - } - - let updateDatabaseTaskSignpostID = OSSignpostID(log: log) - let recordType: WorkingRecord.RecordType = { - switch persistType { - case .public: return .publicTimeline - case .home: return .homeTimeline - case .likeList: return .favoriteTimeline - } - }() - - var workingRecords: [WorkingRecord] = [] - os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - for entity in toots { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) - } - let record = WorkingRecord.createOrMergeToot( - into: managedObjectContext, - for: requestMastodonUser, - domain: domain, - entity: entity, - recordType: recordType, - networkDate: response.networkDate, - log: log - ) - workingRecords.append(record) - } // end for… - os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - - // home & mention timeline tasks - switch persistType { - case .home: - // Task 1: update anchor hasMore - // update maxID anchor hasMore attribute when fetching on timeline - // do not use working records due to anchor toot is removable on the remote - var anchorToot: Toot? - if let maxID = query.maxID { - do { - // load anchor toot from database - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: maxID) - request.returnsObjectsAsFaults = false - request.fetchLimit = 1 - anchorToot = try managedObjectContext.fetch(request).first - if persistType == .home { - let timelineIndex = anchorToot.flatMap { toot in - toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) - } - timelineIndex?.update(hasMore: false) - } else { - assertionFailure() - } - } catch { - assertionFailure(error.localizedDescription) - } - } - - // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database - let _oldestRecord = workingRecords - .sorted(by: { $0.status.createdAt < $1.status.createdAt }) - .first - if let oldestRecord = _oldestRecord { - if let anchorToot = anchorToot { - // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor - let isNoOverlap = mergedOldTootsInTimeline.isEmpty - let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id - let isAnchorEqualOldestRecord = oldestRecord.status.id == anchorToot.id - if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { - if persistType == .home { - let timelineIndex = oldestRecord.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } else { - assertionFailure() - } - } - - } else if mergedOldTootsInTimeline.isEmpty { - // no anchor. set hasMore when no overlap - if persistType == .home { - let timelineIndex = oldestRecord.status.homeTimelineIndexes? - .first(where: { $0.userID == requestMastodonUserID }) - timelineIndex?.update(hasMore: true) - } - } - } else { - // empty working record. mark anchor hasMore in the task 1 - } - default: - break - } - - // print working record tree map - #if DEBUG - DispatchQueue.global(qos: .utility).async { - let logs = workingRecords - .map { record in record.log() } - .joined(separator: "\n") - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs) - let counting = workingRecords - .map { record in record.count() } - .reduce(into: WorkingRecord.Counting(), { result, next in result = result + next }) - let newTootsInTimeLineCount = workingRecords.reduce(0, { result, next in - return next.statusProcessType == .create ? result + 1 : result - }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: toot: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTootsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) - } - #endif - } - .eraseToAnyPublisher() - .handleEvents(receiveOutput: { result in - switch result { - case .success: - break - case .failure(let error): - #if DEBUG - debugPrint(error) - #endif - assertionFailure(error.localizedDescription) - } - }) - .eraseToAnyPublisher() - } -} - -extension APIService.Persist { - - struct WorkingIDRecord { - var statusIDSet: Set - var reblogIDSet: Set - var userIDSet: Set - - enum RecordType { - case timeline - case reblog - } - - init(statusIDSet: Set = Set(), reblogIDSet: Set = Set(), userIDSet: Set = Set()) { - self.statusIDSet = statusIDSet - self.reblogIDSet = reblogIDSet - self.userIDSet = userIDSet - } - - mutating func union(record: WorkingIDRecord) { - statusIDSet = statusIDSet.union(record.statusIDSet) - reblogIDSet = reblogIDSet.union(record.reblogIDSet) - userIDSet = userIDSet.union(record.userIDSet) - } - - static func workingID(entities: [Mastodon.Entity.Status]) -> WorkingIDRecord { - var value = WorkingIDRecord() - for entity in entities { - let child = workingID(entity: entity, recordType: .timeline) - value.union(record: child) - } - return value - } - - private static func workingID(entity: Mastodon.Entity.Status, recordType: RecordType) -> WorkingIDRecord { - var value = WorkingIDRecord() - switch recordType { - case .timeline: value.statusIDSet = Set([entity.id]) - case .reblog: value.reblogIDSet = Set([entity.id]) - } - value.userIDSet = Set([entity.account.id]) - - if let reblog = entity.reblog { - let child = workingID(entity: reblog, recordType: .reblog) - value.union(record: child) - } - return value - } - } - - class WorkingRecord { - - let status: Toot - let children: [WorkingRecord] - let recordType: RecordType - let statusProcessType: ProcessType - let userProcessType: ProcessType - - init( - status: Toot, - children: [APIService.Persist.WorkingRecord], - recordType: APIService.Persist.WorkingRecord.RecordType, - tootProcessType: ProcessType, - userProcessType: ProcessType - ) { - self.status = status - self.children = children - self.recordType = recordType - self.statusProcessType = tootProcessType - self.userProcessType = userProcessType - } - - enum RecordType { - case publicTimeline - case homeTimeline - case mentionTimeline - case userTimeline - case favoriteTimeline - case searchTimeline - - case reblog - - var flag: String { - switch self { - case .publicTimeline: return "P" - case .homeTimeline: return "H" - case .mentionTimeline: return "M" - case .userTimeline: return "U" - case .favoriteTimeline: return "F" - case .searchTimeline: return "S" - case .reblog: return "R" - } - } - } - - enum ProcessType { - case create - case merge - - var flag: String { - switch self { - case .create: return "+" - case .merge: return "-" - } - } - } - - func log(indentLevel: Int = 0) -> String { - let indent = Array(repeating: " ", count: indentLevel).joined() - let tootPreview = status.content.prefix(32).replacingOccurrences(of: "\n", with: " ") - let message = "\(indent)[\(statusProcessType.flag)\(recordType.flag)](\(status.id)) [\(userProcessType.flag)](\(status.author.id))@\(status.author.username) ~> \(tootPreview)" - - var childrenMessages: [String] = [] - for child in children { - childrenMessages.append(child.log(indentLevel: indentLevel + 1)) - } - let result = [[message] + childrenMessages] - .flatMap { $0 } - .joined(separator: "\n") - - return result - } - - struct Counting { - var status = Counter() - var user = Counter() - - static func + (left: Counting, right: Counting) -> Counting { - return Counting( - status: left.status + right.status, - user: left.user + right.user - ) - } - - struct Counter { - var create = 0 - var merge = 0 - - static func + (left: Counter, right: Counter) -> Counter { - return Counter( - create: left.create + right.create, - merge: left.merge + right.merge - ) - } - } - } - - func count() -> Counting { - var counting = Counting() - - switch statusProcessType { - case .create: counting.status.create += 1 - case .merge: counting.status.merge += 1 - } - - switch userProcessType { - case .create: counting.user.create += 1 - case .merge: counting.user.merge += 1 - } - - for child in children { - let childCounting = child.count() - counting = counting + childCounting - } - - return counting - } - - // handle timelineIndex insert with APIService.Persist.createOrMergeToot - static func createOrMergeToot( - into managedObjectContext: NSManagedObjectContext, - for requestMastodonUser: MastodonUser?, - domain: String, - entity: Mastodon.Entity.Status, - recordType: RecordType, - networkDate: Date, - log: OSLog - ) -> WorkingRecord { - let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) - defer { - os_signpost(.end, log: log, name: "update database - process entity: createorMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) - } - - // build tree - let reblogRecord: WorkingRecord? = entity.reblog.flatMap { entity -> WorkingRecord in - createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, recordType: .reblog, networkDate: networkDate, log: log) - } - let children = [reblogRecord].compactMap { $0 } - - let (status, isTootCreated, isTootUserCreated) = APIService.CoreData.createOrMergeToot(into: managedObjectContext, for: requestMastodonUser, entity: entity, domain: domain, networkDate: networkDate, log: log) - - let result = WorkingRecord( - status: status, - children: children, - recordType: recordType, - tootProcessType: isTootCreated ? .create : .merge, - userProcessType: isTootUserCreated ? .create : .merge - ) - - switch (result.statusProcessType, recordType) { - case (.create, .homeTimeline), (.merge, .homeTimeline): - guard let requestMastodonUserID = requestMastodonUser?.id else { - assertionFailure("Request user is required for home timeline") - break - } - let timelineIndex = status.homeTimelineIndexes? - .first { $0.userID == requestMastodonUserID } - if timelineIndex == nil { - let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) - - let _ = HomeTimelineIndex.insert( - into: managedObjectContext, - property: timelineIndexProperty, - toot: status - ) - } else { - // enity already in home timeline - } - default: - break - } - - return result - } - - } - -} - diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift new file mode 100644 index 000000000..1a3cd2985 --- /dev/null +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift @@ -0,0 +1,251 @@ +// +// APIService+Persist+Toot.swift +// Mastodon +// +// Created by sxiaojian on 2021/1/27. +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.Persist { + + enum PersistTimelineType { + case `public` + case home + case likeList + } + + static func persistToots( + managedObjectContext: NSManagedObjectContext, + domain: String, + query: Mastodon.API.Timeline.TimelineQuery, + response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, + persistType: PersistTimelineType, + requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint + log: OSLog + ) -> AnyPublisher, Never> { + return managedObjectContext.performChanges { + let toots = response.value + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) + + let contextTaskSignpostID = OSSignpostID(log: log) + let start = CACurrentMediaTime() + os_signpost(.begin, log: log, name: #function, signpostID: contextTaskSignpostID) + defer { + os_signpost(.end, log: .api, name: #function, signpostID: contextTaskSignpostID) + let end = CACurrentMediaTime() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: persist cost %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + } + + // load request mastodon user + let requestMastodonUser: MastodonUser? = { + guard let requestMastodonUserID = requestMastodonUserID else { return nil } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + // load working set into context to avoid cache miss + let cacheTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + + // contains reblog + let tootCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: toots) + let cachedToots: [Toot] = { + let request = Toot.sortedFetchRequest + let ids = Array(cacheIDs) + request.predicate = Toot.predicate(domain: domain, ids: ids) + request.returnsObjectsAsFaults = false + request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + for toot in cachedToots { + cache.dictionary[toot.id] = toot + } + os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count) + return cache + }() + + let userCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: toots) + let cachedMastodonUsers: [MastodonUser] = { + let request = MastodonUser.sortedFetchRequest + let ids = Array(cacheIDs) + request.predicate = MastodonUser.predicate(domain: domain, ids: ids) + //request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + for mastodonuser in cachedMastodonUsers { + cache.dictionary[mastodonuser.id] = mastodonuser + } + os_signpost(.event, log: log, name: "load user into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld users", cachedMastodonUsers.count) + return cache + }() + + os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + + // remote timeline merge local timeline record set + // declare it before persist + let mergedOldTootsInTimeline = tootCache.dictionary.values.filter { + return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false + } + + let updateDatabaseTaskSignpostID = OSSignpostID(log: log) + let memoType: PersistMemo.MemoType = { + switch persistType { + case .home: return .homeTimeline + case .public: return .publicTimeline + case .likeList: return .likeList + } + }() + + var persistMemos: [PersistMemo] = [] + os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) + for entity in toots { + let processEntityTaskSignpostID = OSSignpostID(log: log) + os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) + defer { + os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) + } + let memo = PersistMemo.createOrMergeToot( + into: managedObjectContext, + for: requestMastodonUser, + requestMastodonUserID: requestMastodonUserID, + domain: domain, + entity: entity, + memoType: memoType, + tootCache: tootCache, + userCache: userCache, + networkDate: response.networkDate, + log: log + ) + persistMemos.append(memo) + } // end for… + os_signpost(.end, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) + + // home timeline tasks + switch persistType { + case .home: + guard let requestMastodonUserID = requestMastodonUserID else { + assertionFailure() + return + } + // Task 1: update anchor hasMore + // update maxID anchor hasMore attribute when fetching on home timeline + // do not use working records due to anchor toot is removable on the remote + var anchorToot: Toot? + if let maxID = query.maxID { + do { + // load anchor toot from database + let request = Toot.sortedFetchRequest + request.predicate = Toot.predicate(domain: domain, id: maxID) + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + anchorToot = try managedObjectContext.fetch(request).first + if persistType == .home { + let timelineIndex = anchorToot.flatMap { toot in + toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) + } + timelineIndex?.update(hasMore: false) + } else { + assertionFailure() + } + } catch { + assertionFailure(error.localizedDescription) + } + } + + // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database + let _oldestMemo = persistMemos + .sorted(by: { $0.status.createdAt < $1.status.createdAt }) + .first + if let oldestMemo = _oldestMemo { + if let anchorToot = anchorToot { + // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor + let isNoOverlap = mergedOldTootsInTimeline.isEmpty + let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id + let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id + if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { + if persistType == .home { + let timelineIndex = oldestMemo.status.homeTimelineIndexes? + .first(where: { $0.userID == requestMastodonUserID }) + timelineIndex?.update(hasMore: true) + } else { + assertionFailure() + } + } + + } else if mergedOldTootsInTimeline.isEmpty { + // no anchor. set hasMore when no overlap + if persistType == .home { + let timelineIndex = oldestMemo.status.homeTimelineIndexes? + .first(where: { $0.userID == requestMastodonUserID }) + timelineIndex?.update(hasMore: true) + } + } + } else { + // empty working record. mark anchor hasMore in the task 1 + } + default: + break + } + + // print working record tree map + #if DEBUG + DispatchQueue.global(qos: .utility).async { + let logs = persistMemos + .map { record in record.log() } + .joined(separator: "\n") + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: working status: \n%s", ((#file as NSString).lastPathComponent), #line, #function, logs) + let counting = persistMemos + .map { record in record.count() } + .reduce(into: PersistMemo.Counting(), { result, next in result = result + next }) + let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in + return next.statusProcessType == .create ? result + 1 : result + }) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) + } + #endif + } + .eraseToAnyPublisher() + .handleEvents(receiveOutput: { result in + switch result { + case .success: + break + case .failure(let error): + #if DEBUG + debugPrint(error) + #endif + assertionFailure(error.localizedDescription) + } + }) + .eraseToAnyPublisher() + } +} From 7556e57de90517cd4d388a3436ee58389383dc15 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 10 Mar 2021 17:14:12 +0800 Subject: [PATCH 069/400] fix: conflict between gif video and audio --- .../Diffiable/Section/StatusSection.swift | 2 +- .../StatusProvider+UITableViewDelegate.swift | 38 ++++++++++++------- .../ViewModel/AudioContainerViewModel.swift | 14 ++++--- .../ViewModel/VideoPlayerViewModel.swift | 38 +++++++++---------- Mastodon/Service/AudioPlayer.swift | 12 +++++- Mastodon/Service/ViedeoPlaybackService.swift | 33 ++++++++++------ 6 files changed, 86 insertions(+), 51 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index e27d64314..1d0169ab8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -168,7 +168,7 @@ extension StatusSection { // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { cell.statusView.audioView.isHidden = false - AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment) + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, videoPlaybackService: dependency.context.videoPlaybackService) } else { cell.statusView.audioView.isHidden = true } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 3411919c1..c68ff6e77 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -5,11 +5,11 @@ // Created by MainasuK Cirno on 2021-3-3. // -import os.log -import UIKit import Combine import CoreDataStack import MastodonSDK +import os.log +import UIKit extension StatusTableViewCellDelegate where Self: StatusProvider { // TODO: @@ -29,20 +29,20 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // not expired AND last update > 60s guard !poll.expired else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s expired. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id) return nil } let timeIntervalSinceUpdate = now.timeIntervalSince(poll.updatedAt) #if DEBUG - let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing + let autoRefreshTimeInterval: TimeInterval = 3 // speedup testing #else let autoRefreshTimeInterval: TimeInterval = 60 #endif guard timeIntervalSinceUpdate > autoRefreshTimeInterval else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", ((#file as NSString).lastPathComponent), #line, #function, poll.id, timeIntervalSinceUpdate) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s updated in the %.2fs. Skip for update", (#file as NSString).lastPathComponent, #line, #function, poll.id, timeIntervalSinceUpdate) return nil } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id) return self.context.apiService.poll( domain: toot.domain, @@ -57,13 +57,13 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { .sink(receiveCompletion: { completion in switch completion { case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", ((#file as NSString).lastPathComponent), #line, #function, pollID ?? "?", error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info fail to update: %s", (#file as NSString).lastPathComponent, #line, #function, pollID ?? "?", error.localizedDescription) case .finished: break } }, receiveValue: { response in let poll = response.value - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", ((#file as NSString).lastPathComponent), #line, #function, poll.id) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info updated", (#file as NSString).lastPathComponent, #line, #function, poll.id) }) .store(in: &disposeBag) @@ -79,10 +79,22 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } .store(in: &disposeBag) } - -} - -extension StatusTableViewCellDelegate where Self: StatusProvider { - + func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + toot(for: cell, indexPath: indexPath) + .sink { [weak self] toot in + guard let self = self else { return } + guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } + + DispatchQueue.main.async { + videoPlayerViewModel.didEndDisplaying() + } + } + .store(in: &disposeBag) + } } + +extension StatusTableViewCellDelegate where Self: StatusProvider {} diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 70509d40e..7370c4fef 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -12,7 +12,8 @@ import UIKit class AudioContainerViewModel { static func configure( cell: StatusTableViewCell, - audioAttachment: Attachment + audioAttachment: Attachment, + videoPlaybackService: VideoPlaybackService ) { guard let duration = audioAttachment.meta?.original?.duration else { return } let audioView = cell.statusView.audioView @@ -25,12 +26,15 @@ class AudioContainerViewModel { AudioPlayer.shared.pause() } else { AudioPlayer.shared.resume() + videoPlaybackService.pauseWhenPlayAudio() } if AudioPlayer.shared.currentTimeSubject.value == 0 { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + videoPlaybackService.pauseWhenPlayAudio() } } else { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + videoPlaybackService.pauseWhenPlayAudio() } } .store(in: &cell.disposeBag) @@ -41,7 +45,7 @@ class AudioContainerViewModel { AudioPlayer.shared.seekToTime(time: time) } .store(in: &cell.disposeBag) - self.observePlayer(cell: cell, audioAttachment: audioAttachment) + observePlayer(cell: cell, audioAttachment: audioAttachment) if audioAttachment != AudioPlayer.shared.attachment { configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } @@ -61,11 +65,11 @@ class AudioContainerViewModel { } guard audioAttachment === AudioPlayer.shared.attachment else { return nil } guard let duration = audioAttachment.meta?.original?.duration else { return nil } - + if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 { - guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce + guard abs(time - lastCurrentTimeSubject) < 0.5 else { return nil } // debounce } - + guard !audioView.slider.isTracking else { return nil } return (time, Float(time / duration)) } diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 7d2f68091..e0a2f5ef4 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -5,14 +5,13 @@ // Created by xiaojian sun on 2021/3/10. // +import AVKit +import Combine +import CoreDataStack import os.log import UIKit -import AVKit -import CoreDataStack -import Combine final class VideoPlayerViewModel { - var disposeBag = Set() // input @@ -33,7 +32,7 @@ final class VideoPlayerViewModel { // output let player: AVPlayer - private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) + private(set) var looper: AVPlayerLooper? // works with AVQueuePlayer (iOS 10+) private var timeControlStatusObservation: NSKeyValueObservation? let timeControlStatus = CurrentValueSubject(.paused) @@ -55,7 +54,7 @@ final class VideoPlayerViewModel { timeControlStatusObservation = player.observe(\.timeControlStatus, options: [.initial, .new]) { [weak self] player, _ in guard let self = self else { return } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", ((#file as NSString).lastPathComponent), #line, #function, player.timeControlStatus.debugDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: player state: %s", (#file as NSString).lastPathComponent, #line, #function, player.timeControlStatus.debugDescription) self.timeControlStatus.value = player.timeControlStatus } @@ -64,11 +63,13 @@ final class VideoPlayerViewModel { .sink { [weak self] timeControlStatus in guard let _ = self else { return } guard timeControlStatus == .playing else { return } + AudioPlayer.shared.pauseIfNeed() switch videoKind { - case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) - case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } - try? AVAudioSession.sharedInstance().setActive(true) } .store(in: &disposeBag) @@ -84,7 +85,6 @@ final class VideoPlayerViewModel { deinit { timeControlStatusObservation = nil } - } extension VideoPlayerViewModel { @@ -95,7 +95,6 @@ extension VideoPlayerViewModel { } extension VideoPlayerViewModel { - func setupLooper() { guard looper == nil, let queuePlayer = player as? AVQueuePlayer else { return } guard let templateItem = queuePlayer.items().first else { return } @@ -104,10 +103,12 @@ extension VideoPlayerViewModel { func play() { switch videoKind { - case .gif: try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) - case .video: try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) + case .gif: + break + case .video: + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) } - try? AVAudioSession.sharedInstance().setActive(true) + player.play() updateDate = Date() } @@ -118,11 +119,11 @@ extension VideoPlayerViewModel { } func willDisplay() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) switch videoKind { case .gif: - play() // always auto play GIF + play() // always auto play GIF case .video: guard isPlayingWhenEndDisplaying else { return } // mute before resume @@ -136,17 +137,16 @@ extension VideoPlayerViewModel { } func didEndDisplaying() { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", ((#file as NSString).lastPathComponent), #line, #function, videoURL.debugDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: url: %s", (#file as NSString).lastPathComponent, #line, #function, videoURL.debugDescription) isPlayingWhenEndDisplaying = timeControlStatus.value != .paused switch videoKind { case .gif: - pause() // always pause GIF immediately + pause() // always pause GIF immediately case .video: debouncePlayingState.send(false) } updateDate = Date() } - } diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 13479f0af..02c948c29 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -111,6 +111,12 @@ extension AudioPlayer { self.currentTimeSubject.value = 0 } .store(in: &disposeBag) + NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification, object: nil) + .sink { [weak self] _ in + guard let self = self else { return } + self.pause() + } + .store(in: &disposeBag) } func isPlaying() -> Bool { @@ -125,7 +131,11 @@ extension AudioPlayer { player.pause() playbackState.value = .paused } - + func pauseIfNeed() { + if isPlaying() { + pause() + } + } func seekToTime(time: TimeInterval) { player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) } diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 5f1f2a121..724026fdd 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -5,14 +5,13 @@ // Created by xiaojian sun on 2021/3/10. // -import os.log -import Foundation import AVKit import Combine import CoreDataStack +import Foundation +import os.log final class VideoPlaybackService { - var disposeBag = Set() let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") @@ -20,7 +19,6 @@ final class VideoPlaybackService { // only for video kind weak var latestPlayingVideoPlayerViewModel: VideoPlayerViewModel? - } extension VideoPlaybackService { @@ -43,7 +41,6 @@ extension VideoPlaybackService { if latestPlayingVideoPlayerViewModel === playerViewModel { latestPlayingVideoPlayerViewModel = nil try? AVAudioSession.sharedInstance().setCategory(.soloAmbient, mode: .default) - try? AVAudioSession.sharedInstance().setActive(false) } } } @@ -51,16 +48,15 @@ extension VideoPlaybackService { } extension VideoPlaybackService { - func dequeueVideoPlayerViewModel(for media: Attachment) -> VideoPlayerViewModel? { // Core Data entity not thread-safe. Save attribute before enter working queue guard let height = media.meta?.original?.height, let width = media.meta?.original?.width, let url = URL(string: media.url), - media.type == .gifv || media.type == .video else - { return nil } + media.type == .gifv || media.type == .video + else { return nil } - let previewImageURL = media.previewURL.flatMap({ URL(string: $0) }) + let previewImageURL = media.previewURL.flatMap { URL(string: $0) } let videoKind: VideoPlayerViewModel.Kind = media.type == .gifv ? .gif : .video var _viewModel: VideoPlayerViewModel? @@ -95,7 +91,6 @@ extension VideoPlaybackService { } .store(in: &disposeBag) } - } extension VideoPlaybackService { @@ -106,7 +101,7 @@ extension VideoPlaybackService { } func viewDidDisappear(from viewController: UIViewController?) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) // note: do not retain view controller // pause all player when view disppear exclude full screen player and other transitioning scene @@ -116,11 +111,25 @@ extension VideoPlaybackService { continue } guard !viewModel.isFullScreenPresentationing else { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) continue } guard viewModel.videoKind == .video else { continue } viewModel.pause() } } + + func pauseWhenPlayAudio() { + for viewModel in viewPlayerViewModelDict.values { + guard !viewModel.isTransitioning else { + viewModel.isTransitioning = false + continue + } + guard !viewModel.isFullScreenPresentationing else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: isFullScreenPresentationing", (#file as NSString).lastPathComponent, #line, #function) + continue + } + viewModel.pause() + } + } } From 75d39aabf04b1beae771f6fc30db9e542ac3836a Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 10 Mar 2021 19:12:53 +0800 Subject: [PATCH 070/400] feat: add reply to header for toot --- CoreDataStack/Entity/Toot.swift | 11 +++ Localization/app.json | 1 + Mastodon.xcodeproj/project.pbxproj | 12 +++ .../Diffiable/Section/StatusSection.swift | 43 ++++++++-- Mastodon/Generated/Strings.swift | 4 + ...Provider+StatusTableViewCellDelegate.swift | 2 +- ...der+UITableViewDataSourcePrefetching.swift | 50 +++++++++++ .../StatusProvider/StatusProvider.swift | 1 + .../Resources/en.lproj/Localizable.strings | 1 + ...imelineViewController+StatusProvider.swift | 14 ++++ .../HomeTimelineViewController.swift | 8 ++ .../MastodonPickServerViewController.swift | 2 +- ...rverViewModel+LoadIndexedServerState.swift | 4 +- .../MastodonPickServerViewModel.swift | 2 +- ...imelineViewController+StatusProvider.swift | 14 ++++ .../PublicTimelineViewController.swift | 8 ++ .../Scene/Share/View/Content/StatusView.swift | 32 +++++-- .../APIService/APIService+Status.swift | 54 ++++++++++++ .../Persist/APIService+Persist+Toot.swift | 7 +- .../Service/StatusPrefetchingService.swift | 84 +++++++++++++++++++ Mastodon/State/AppContext.swift | 6 ++ .../API/Mastodon+API+Statuses.swift | 53 ++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 23 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift create mode 100644 Mastodon/Service/APIService/APIService+Status.swift create mode 100644 Mastodon/Service/StatusPrefetchingService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Toot.swift index 3a2c91775..a2ad3b5a8 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Toot.swift @@ -145,16 +145,19 @@ public extension Toot { return toot } + func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount } } + func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } + func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return @@ -163,6 +166,13 @@ public extension Toot { self.repliesCount = repliesCount } } + + func update(replyTo: Toot?) { + if self.replyTo != replyTo { + self.replyTo = replyTo + } + } + func update(liked: Bool, mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { @@ -174,6 +184,7 @@ public extension Toot { } } } + func update(reblogged: Bool, mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { diff --git a/Localization/app.json b/Localization/app.json index 123655955..5d86f1f81 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -37,6 +37,7 @@ }, "status": { "user_boosted": "%s boosted", + "user_replied_to": "Replied to %s", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5528d0234..6332da884 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -151,6 +151,9 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */; }; + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */; }; + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -399,6 +402,9 @@ DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDataSourcePrefetching.swift"; sourceTree = ""; }; + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPrefetchingService.swift; sourceTree = ""; }; + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -606,6 +612,7 @@ 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */, 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */, DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */, + DB71FD4525F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift */, ); path = StatusProvider; sourceTree = ""; @@ -655,6 +662,7 @@ DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, + DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, ); path = Service; sourceTree = ""; @@ -933,6 +941,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, ); path = APIService; sourceTree = ""; @@ -1566,6 +1575,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, + DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -1577,6 +1587,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, @@ -1589,6 +1600,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 489b9d3b8..a6b9397ad 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -79,12 +79,17 @@ extension StatusSection { statusItemAttribute: Item.StatusAttribute ) { // set header - cell.statusView.headerContainerStackView.isHidden = toot.reblog == nil - cell.statusView.headerInfoLabel.text = { - let author = toot.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) - }() + StatusSection.configureHeader(cell: cell, toot: toot) + ManagedObjectObserver.observe(object: toot) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newToot = object as? Toot else { return } + StatusSection.configureHeader(cell: cell, toot: newToot) + } + .store(in: &cell.disposeBag) // set name username avatar cell.statusView.nameLabel.text = { @@ -225,7 +230,6 @@ extension StatusSection { guard case .update(let object) = change.changeType, let newToot = object as? Toot else { return } let targetToot = newToot.reblog ?? newToot - let isLike = targetToot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCount = targetToot.favouritesCount.intValue let favoriteCountTitle = StatusSection.formattedNumberTitleForActionButton(favoriteCount) @@ -236,6 +240,31 @@ extension StatusSection { .store(in: &cell.disposeBag) } + static func configureHeader( + cell: StatusTableViewCell, + toot: Toot + ) { + if toot.reblog != nil { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerInfoLabel.text = { + let author = toot.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userBoosted(name) + }() + } else if let replyTo = toot.replyTo { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userRepliedTo(name) + }() + } else { + cell.statusView.headerContainerStackView.isHidden = true + } + } + static func configure( cell: StatusTableViewCell, poll: Poll?, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918e..74595d5b6 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -80,6 +80,10 @@ internal enum L10n { internal static func userBoosted(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) } + /// Replied to %@ + internal static func userRepliedTo(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) + } internal enum Poll { /// Closed internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cd4e5160d..12dfeadf5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -119,7 +119,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let diffableDataSource = cell.statusView.pollTableViewDataSource else { return } let item = diffableDataSource.itemIdentifier(for: indexPath) - guard case let .opion(objectID, attribute) = item else { return } + guard case let .opion(objectID, _) = item else { return } guard let option = managedObjectContext.object(with: objectID) as? PollOption else { return } let poll = option.poll diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift new file mode 100644 index 000000000..20f8e30a9 --- /dev/null +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -0,0 +1,50 @@ +// +// StatusProvider+UITableViewDataSourcePrefetching.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import UIKit +import CoreData +import CoreDataStack + +extension StatusTableViewCellDelegate where Self: StatusProvider { + func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + // prefetch reply toot + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for item in items(indexPaths: indexPaths) { + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex + statusObjectIDs.append(homeTimelineIndex.toot.objectID) + case .toot(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot + guard let replyToID = toot.inReplyToID, toot.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: toot.objectID, + statusID: toot.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index a0a7116fc..4ec0d1e16 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -20,4 +20,5 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl var managedObjectContext: NSManagedObjectContext { get } var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? + func items(indexPaths: [IndexPath]) -> [Item] } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e274..a1a6db78e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -31,6 +31,7 @@ "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index a0d9204ba..dc8eb4803 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension HomeTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b9d0f94e1..db285c2c3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -103,6 +103,7 @@ extension HomeTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -239,6 +240,13 @@ extension HomeTimelineViewController: UITableViewDelegate { } +// MARK: - UITableViewDataSourcePrefetching +extension HomeTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index f3e811b9f..37e8b0dab 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -375,7 +375,7 @@ extension MastodonPickServerViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } - guard case let .server(server) = item else { return nil } + guard case .server = item else { return nil } if tableView.indexPathForSelectedRow == indexPath { tableView.deselectRow(at: indexPath, animated: false) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift index b9cdcc7e4..69f6a82fb 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel+LoadIndexedServerState.swift @@ -45,7 +45,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { viewModel.context.apiService.servers(language: nil, category: nil) .sink { completion in switch completion { - case .failure(let error): + case .failure: // TODO: handle error stateMachine.enter(Fail.self) case .finished: @@ -84,7 +84,7 @@ extension MastodonPickServerViewModel.LoadIndexedServerState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = self.viewModel, let stateMachine = self.stateMachine else { return } + guard let viewModel = self.viewModel else { return } viewModel.isLoadingIndexedServers.value = false } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e136e86ce..09b6c327c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -176,7 +176,7 @@ class MastodonPickServerViewModel: NSObject { switch result { case .success(let response): self.unindexedServers.send(response.value) - case .failure(let error): + case .failure: // TODO: What should be presented when user inputs invalid search text? self.unindexedServers.send([]) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index aceb83718..7d52a5764 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -70,4 +70,18 @@ extension PublicTimelineViewController: StatusProvider { return item } + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 98d2dbd94..ddca93fe7 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -73,6 +73,7 @@ extension PublicTimelineViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -125,6 +126,13 @@ extension PublicTimelineViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension PublicTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate extension PublicTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2713647fe..f908ce5c1 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -24,6 +24,29 @@ final class StatusView: UIView { static let avatarImageCornerRadius: CGFloat = 4 static let contentWarningBlurRadius: CGFloat = 12 + static let boostIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static let replyIconImage: UIImage = { + let font = UIFont.systemFont(ofSize: 13, weight: .medium) + let configuration = UIImage.SymbolConfiguration(font: font) + let image = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) + return image + }() + + static func iconAttributedString(image: UIImage) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + let imageTextAttachment = NSTextAttachment() + let imageAttribute = NSAttributedString(attachment: imageTextAttachment) + imageTextAttachment.image = image + attributedString.append(imageAttribute) + return attributedString + } + weak var delegate: StatusViewDelegate? var isStatusTextSensitive = false var pollTableViewDataSource: UITableViewDiffableDataSource? @@ -33,14 +56,7 @@ final class StatusView: UIView { let headerIconLabel: UILabel = { let label = UILabel() - let attributedString = NSMutableAttributedString() - let imageTextAttachment = NSTextAttachment() - let font = UIFont.systemFont(ofSize: 13, weight: .medium) - let configuration = UIImage.SymbolConfiguration(font: font) - imageTextAttachment.image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)?.withTintColor(Asset.Colors.Label.secondary.color) - let imageAttribute = NSAttributedString(attachment: imageTextAttachment) - attributedString.append(imageAttribute) - label.attributedText = attributedString + label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) return label }() diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..88ac064af --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,54 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func status( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + return Mastodon.API.Statuses.status( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistToots( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { [$0] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift index 1a3cd2985..fb2c2e6c0 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift @@ -19,12 +19,13 @@ extension APIService.Persist { case `public` case home case likeList + case lookUp } static func persistToots( managedObjectContext: NSManagedObjectContext, domain: String, - query: Mastodon.API.Timeline.TimelineQuery, + query: Mastodon.API.Timeline.TimelineQuery?, response: Mastodon.Response.Content<[Mastodon.Entity.Status]>, persistType: PersistTimelineType, requestMastodonUserID: MastodonUser.ID?, // could be nil when response from public endpoint @@ -122,6 +123,7 @@ extension APIService.Persist { case .home: return .homeTimeline case .public: return .publicTimeline case .likeList: return .likeList + case .lookUp: return .lookUp } }() @@ -152,7 +154,8 @@ extension APIService.Persist { // home timeline tasks switch persistType { case .home: - guard let requestMastodonUserID = requestMastodonUserID else { + guard let query = query, + let requestMastodonUserID = requestMastodonUserID else { assertionFailure() return } diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift new file mode 100644 index 000000000..d4332fe16 --- /dev/null +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -0,0 +1,84 @@ +// +// StatusPrefetchingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPrefetchingService { + + typealias TaskID = String + + let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue") + + var disposeBag = Set() + private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] + + weak var apiService: APIService? + + init(apiService: APIService) { + self.apiService = apiService + } + +} + +extension StatusPrefetchingService { + + func prefetchReplyTo( + domain: String, + statusObjectID: NSManagedObjectID, + statusID: Mastodon.Entity.Status.ID, + replyToStatusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + workingQueue.async { [weak self] in + guard let self = self, let apiService = self.apiService else { return } + let taskID = domain + "@" + statusID + "->" + replyToStatusID + guard self.statusPrefetchingDisposeBagDict[taskID] == nil else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefetching replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + + self.statusPrefetchingDisposeBagDict[taskID] = apiService.status( + domain: domain, + statusID: replyToStatusID, + authorizationBox: authorizationBox + ) + .sink(receiveCompletion: { [weak self] completion in + // remove task when completed + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: prefeched replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + self.statusPrefetchingDisposeBagDict[taskID] = nil + }, receiveValue: { [weak self] _ in + guard let self = self else { return } + let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext + backgroundManagedObjectContext.performChanges { + guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return } + do { + let predicate = Toot.predicate(domain: domain, id: replyToStatusID) + let request = Toot.sortedFetchRequest + request.predicate = predicate + request.returnsObjectsAsFaults = false + request.fetchLimit = 1 + guard let replyTo = try backgroundManagedObjectContext.fetch(request).first else { return } + status.update(replyTo: replyTo) + } catch { + assertionFailure(error.localizedDescription) + } + } + .sink { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update status replyTo: %s", ((#file as NSString).lastPathComponent), #line, #function, taskID) + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) + } + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 08918496b..ffa1321ab 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -24,6 +24,8 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService + let statusPrefetchingService: StatusPrefetchingService + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -46,6 +48,10 @@ class AppContext: ObservableObject { apiService: _apiService ) + statusPrefetchingService = StatusPrefetchingService( + apiService: _apiService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift new file mode 100644 index 000000000..cb33bc09b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -0,0 +1,53 @@ +// +// Mastodon+API+Statuses.swift +// +// +// Created by MainasuK Cirno on 2021-3-10. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + let pathComponent = "statuses/" + statusID + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// View specific status + /// + /// View information about a status + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/10 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func status( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: viewStatusEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 073d926e9..5516a0492 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -95,6 +95,7 @@ extension Mastodon.API { public enum OAuth { } public enum Onboarding { } public enum Polls { } + public enum Statuses { } public enum Timeline { } public enum Favorites { } } From 2657dde18496981d0ec3b6344e238aeb1d3d03c9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 10 Mar 2021 21:19:56 +0800 Subject: [PATCH 071/400] chore: the play interrupts event could be sent with the notification --- Mastodon/Diffiable/Section/StatusSection.swift | 14 +++++++++++--- .../HomeTimelineViewController.swift | 7 +++++-- .../PublicTimelineViewController.swift | 10 +++++++++- .../ViewModel/AudioContainerViewModel.swift | 6 +----- .../Share/ViewModel/VideoPlayerViewModel.swift | 3 ++- Mastodon/Service/AudioPlayer.swift | 16 +++++++++++++++- Mastodon/Service/ViedeoPlaybackService.swift | 7 +++++++ 7 files changed, 50 insertions(+), 13 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1d0169ab8..53f7aec87 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -37,7 +37,11 @@ extension StatusSection { StatusSection.configure( cell: cell, dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: timelineIndex.toot, requestUserID: timelineIndex.userID, statusItemAttribute: attribute + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + toot: timelineIndex.toot, + requestUserID: timelineIndex.userID, + statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate @@ -52,7 +56,11 @@ extension StatusSection { StatusSection.configure( cell: cell, dependency: dependency, - readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, toot: toot, requestUserID: requestUserID, statusItemAttribute: attribute + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + toot: toot, + requestUserID: requestUserID, + statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate @@ -168,7 +176,7 @@ extension StatusSection { // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { cell.statusView.audioView.isHidden = false - AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, videoPlaybackService: dependency.context.videoPlaybackService) + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment ) } else { cell.statusView.audioView.isHidden = true } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index c9498dbea..01b860da8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -141,7 +141,7 @@ extension HomeTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - + context.videoPlaybackService.viewDidDisappear(from: self) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -236,7 +236,10 @@ extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) } - + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 50d36d296..820094b1e 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -81,6 +81,10 @@ extension PublicTimelineViewController { ) } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + context.videoPlaybackService.viewDidDisappear(from: self) + } } // MARK: - UIScrollViewDelegate @@ -103,6 +107,7 @@ extension PublicTimelineViewController { // MARK: - UITableViewDelegate extension PublicTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } @@ -114,8 +119,11 @@ extension PublicTimelineViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} - + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 7370c4fef..de804a475 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -12,8 +12,7 @@ import UIKit class AudioContainerViewModel { static func configure( cell: StatusTableViewCell, - audioAttachment: Attachment, - videoPlaybackService: VideoPlaybackService + audioAttachment: Attachment ) { guard let duration = audioAttachment.meta?.original?.duration else { return } let audioView = cell.statusView.audioView @@ -26,15 +25,12 @@ class AudioContainerViewModel { AudioPlayer.shared.pause() } else { AudioPlayer.shared.resume() - videoPlaybackService.pauseWhenPlayAudio() } if AudioPlayer.shared.currentTimeSubject.value == 0 { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) - videoPlaybackService.pauseWhenPlayAudio() } } else { AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) - videoPlaybackService.pauseWhenPlayAudio() } } .store(in: &cell.disposeBag) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index e0a2f5ef4..c3f2cf369 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -14,6 +14,7 @@ import UIKit final class VideoPlayerViewModel { var disposeBag = Set() + static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "appWillPlayVideoNotification") // input let previewImageURL: URL? let videoURL: URL @@ -63,7 +64,7 @@ final class VideoPlayerViewModel { .sink { [weak self] timeControlStatus in guard let _ = self else { return } guard timeControlStatus == .playing else { return } - AudioPlayer.shared.pauseIfNeed() + NotificationCenter.default.post(name: VideoPlayerViewModel.appWillPlayVideoNotification, object: nil) switch videoKind { case .gif: break diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlayer.swift index 02c948c29..646207edb 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlayer.swift @@ -12,6 +12,9 @@ import Foundation import UIKit final class AudioPlayer: NSObject { + + static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "appWillPlayAudioNotification") + var disposeBag = Set() var player = AVPlayer() @@ -45,6 +48,7 @@ extension AudioPlayer { return } + pushWillPlayAudioNotification() if audioAttachment == attachment { if self.playbackState.value == .stopped { self.seekToTime(time: .zero) @@ -83,6 +87,12 @@ extension AudioPlayer { } } .store(in: &disposeBag) + NotificationCenter.default.publisher(for: VideoPlayerViewModel.appWillPlayVideoNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.pauseIfNeed() + } + .store(in: &disposeBag) timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: CMTimeScale(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] time in guard let self = self else { return } @@ -119,10 +129,14 @@ extension AudioPlayer { .store(in: &disposeBag) } + func pushWillPlayAudioNotification() { + NotificationCenter.default.post(name: AudioPlayer.appWillPlayAudioNotification, object: nil) + } func isPlaying() -> Bool { - return self.playbackState.value == .readyToPlay || self.playbackState.value == .playing + return playbackState.value == .readyToPlay || playbackState.value == .playing } func resume() { + pushWillPlayAudioNotification() player.play() playbackState.value = .playing } diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 724026fdd..49ac3b0df 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -90,6 +90,13 @@ extension VideoPlaybackService { self.playerViewModel(viewModel, didUpdateTimeControlStatus: timeControlStatus) } .store(in: &disposeBag) + + NotificationCenter.default.publisher(for: AudioPlayer.appWillPlayAudioNotification) + .sink { [weak self] _ in + guard let self = self else { return } + self.pauseWhenPlayAudio() + } + .store(in: &disposeBag) } } From 0be862c6b3b129190c18c4c23c4d311ae6cb1bdc Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:19:07 +0800 Subject: [PATCH 072/400] chore: remove useless extension for UIControl.State. Correct AvatarStackContainerButton filename --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- .../AvatarStackContainerButton.swift} | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) rename Mastodon/Scene/Share/View/{Container/AvatarStackContainerView.swift => Control/AvatarStackContainerButton.swift} (98%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d6d3afb00..439b8ba9c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,7 +149,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; - DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */; }; + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; @@ -397,7 +397,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerView.swift; sourceTree = ""; }; + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1170,7 +1170,6 @@ children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, - DB71FD2B25F86A5100512AE1 /* AvatarStackContainerView.swift */, ); path = Container; sourceTree = ""; @@ -1187,6 +1186,7 @@ DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( + DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, ); path = Control; @@ -1687,7 +1687,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, - DB71FD2C25F86A5100512AE1 /* AvatarStackContainerView.swift in Sources */, + DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, diff --git a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift similarity index 98% rename from Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift rename to Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift index ad828d9d2..1e4bd24fe 100644 --- a/Mastodon/Scene/Share/View/Container/AvatarStackContainerView.swift +++ b/Mastodon/Scene/Share/View/Control/AvatarStackContainerButton.swift @@ -1,5 +1,5 @@ // -// AvatarStackContainerView.swift +// AvatarStackContainerButton.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-10. @@ -17,8 +17,6 @@ extension AvatarStackedImageView: AvatarConfigurableView { var configurableAvatarButton: UIButton? { nil } } -extension UIControl.State: Hashable { } - final class AvatarStackContainerButton: UIControl { static let containerSize = CGSize(width: 42, height: 42) From 51ee576c24acfb8bab27b31d4013a4a2d2acd524 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:36:42 +0800 Subject: [PATCH 073/400] fix: redundant execute path for avatar setting --- Mastodon/Diffiable/Section/StatusSection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 9d4fcad92..bd6b805ec 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -101,7 +101,7 @@ extension StatusSection { } else { cell.statusView.avatarButton.isHidden = false cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: (toot.reblog ?? toot).author.avatarImageURL())) + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) } // set text From f4136d03bad16d2afaea1dfb356afd371af22368 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 11:43:49 +0800 Subject: [PATCH 074/400] fix: add missing fetchLimit --- Mastodon/Service/APIService/APIService+Favorite.swift | 1 + Mastodon/Service/APIService/APIService+Reblog.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 98b00cf18..2577b6cb0 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -79,6 +79,7 @@ extension APIService { let _oldToot: Toot? = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 55b4699ae..5525fe5bf 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -85,6 +85,7 @@ extension APIService { let _oldToot: Toot? = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: domain, id: statusID) + request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] do { From 6c0a767435ee0aa3bac7f2b6bd830a1503f57e7a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 11 Mar 2021 13:11:13 +0800 Subject: [PATCH 075/400] chore: auto-pause when audio cell disappeared --- Mastodon.xcodeproj/project.pbxproj | 8 ++-- .../Diffiable/Section/StatusSection.swift | 2 +- .../StatusProvider+UITableViewDelegate.swift | 10 +++-- .../HomeTimelineViewController.swift | 1 + .../PublicTimelineViewController.swift | 2 +- .../ViewModel/AudioContainerViewModel.swift | 42 ++++++++++--------- .../ViewModel/VideoPlayerViewModel.swift | 2 +- ...layer.swift => AudioPlaybackService.swift} | 27 +++++++----- Mastodon/Service/ViedeoPlaybackService.swift | 2 +- Mastodon/State/AppContext.swift | 1 + 10 files changed, 56 insertions(+), 41 deletions(-) rename Mastodon/Service/{AudioPlayer.swift => AudioPlaybackService.swift} (87%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 69f431909..98bfe2074 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -25,7 +25,7 @@ 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; - 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlayer.swift */; }; + 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; }; 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; @@ -269,7 +269,7 @@ 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; - 2D206B8B25F6015000143C56 /* AudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayer.swift; sourceTree = ""; }; + 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; @@ -659,7 +659,7 @@ DB45FB0425CA87B4005A8AC7 /* APIService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, - 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, + 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, ); @@ -1569,7 +1569,7 @@ DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, - 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, + 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 53f7aec87..daaa98523 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -176,7 +176,7 @@ extension StatusSection { // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { cell.statusView.audioView.isHidden = false - AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment ) + AudioContainerViewModel.configure(cell: cell, audioAttachment: audioAttachment, audioService: dependency.context.audioPlaybackService) } else { cell.statusView.audioView.isHidden = true } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index c68ff6e77..1157b7993 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -87,10 +87,14 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { .sink { [weak self] toot in guard let self = self else { return } guard let media = (toot?.mediaAttachments ?? Set()).first else { return } - guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } - DispatchQueue.main.async { - videoPlayerViewModel.didEndDisplaying() + if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) { + DispatchQueue.main.async { + videoPlayerViewModel.didEndDisplaying() + } + } + if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = toot?.mediaAttachments?.contains(currentAudioAttachment) { + self.context.audioPlaybackService.pause() } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 01b860da8..9db551f62 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -142,6 +142,7 @@ extension HomeTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 820094b1e..1fc0978e2 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -84,6 +84,7 @@ extension PublicTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) } } @@ -107,7 +108,6 @@ extension PublicTimelineViewController { // MARK: - UITableViewDelegate extension PublicTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index de804a475..56bf0cbc3 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -12,54 +12,58 @@ import UIKit class AudioContainerViewModel { static func configure( cell: StatusTableViewCell, - audioAttachment: Attachment + audioAttachment: Attachment, + audioService: AudioPlaybackService ) { guard let duration = audioAttachment.meta?.original?.duration else { return } let audioView = cell.statusView.audioView audioView.timeLabel.text = duration.asString(style: .positional) audioView.playButton.publisher(for: .touchUpInside) - .sink { _ in - if audioAttachment === AudioPlayer.shared.attachment { - if AudioPlayer.shared.isPlaying() { - AudioPlayer.shared.pause() + .sink { [weak audioService] _ in + guard let audioService = audioService else { return } + if audioAttachment === audioService.attachment { + if audioService.isPlaying() { + audioService.pause() } else { - AudioPlayer.shared.resume() + audioService.resume() } - if AudioPlayer.shared.currentTimeSubject.value == 0 { - AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + if audioService.currentTimeSubject.value == 0 { + audioService.playAudio(audioAttachment: audioAttachment) } } else { - AudioPlayer.shared.playAudio(audioAttachment: audioAttachment) + audioService.playAudio(audioAttachment: audioAttachment) } } .store(in: &cell.disposeBag) audioView.slider.publisher(for: .valueChanged) - .sink { slider in + .sink { [weak audioService] slider in + guard let audioService = audioService else { return } let slider = slider as! UISlider let time = Double(slider.value) * duration - AudioPlayer.shared.seekToTime(time: time) + audioService.seekToTime(time: time) } .store(in: &cell.disposeBag) - observePlayer(cell: cell, audioAttachment: audioAttachment) - if audioAttachment != AudioPlayer.shared.attachment { + observePlayer(cell: cell, audioAttachment: audioAttachment, audioService: audioService) + if audioAttachment != audioService.attachment { configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) } } static func observePlayer( cell: StatusTableViewCell, - audioAttachment: Attachment + audioAttachment: Attachment, + audioService: AudioPlaybackService ) { let audioView = cell.statusView.audioView var lastCurrentTimeSubject: TimeInterval? - AudioPlayer.shared.currentTimeSubject + audioService.currentTimeSubject .throttle(for: 0.33, scheduler: DispatchQueue.main, latest: true) - .compactMap { time -> (TimeInterval, Float)? in + .compactMap { [weak audioService] time -> (TimeInterval, Float)? in defer { lastCurrentTimeSubject = time } - guard audioAttachment === AudioPlayer.shared.attachment else { return nil } + guard audioAttachment === audioService?.attachment else { return nil } guard let duration = audioAttachment.meta?.original?.duration else { return nil } if let lastCurrentTimeSubject = lastCurrentTimeSubject, time != 0.0 { @@ -74,10 +78,10 @@ class AudioContainerViewModel { audioView.slider.setValue(progress, animated: true) }) .store(in: &cell.disposeBag) - AudioPlayer.shared.playbackState + audioService.playbackState .receive(on: DispatchQueue.main) .sink(receiveValue: { playbackState in - if audioAttachment === AudioPlayer.shared.attachment { + if audioAttachment === audioService.attachment { configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: playbackState) } else { configureAudioView(audioView: audioView, audioAttachment: audioAttachment, playbackState: .stopped) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index c3f2cf369..3fa241486 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -14,7 +14,7 @@ import UIKit final class VideoPlayerViewModel { var disposeBag = Set() - static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "appWillPlayVideoNotification") + static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.VideoPlayerViewModel.appWillPlayVideo") // input let previewImageURL: URL? let videoURL: URL diff --git a/Mastodon/Service/AudioPlayer.swift b/Mastodon/Service/AudioPlaybackService.swift similarity index 87% rename from Mastodon/Service/AudioPlayer.swift rename to Mastodon/Service/AudioPlaybackService.swift index 646207edb..314f39649 100644 --- a/Mastodon/Service/AudioPlayer.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -10,10 +10,11 @@ import Combine import CoreDataStack import Foundation import UIKit +import os.log -final class AudioPlayer: NSObject { +final class AudioPlaybackService: NSObject { - static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "appWillPlayAudioNotification") + static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.AudioPlayer.appWillPlayAudio") var disposeBag = Set() @@ -24,19 +25,16 @@ final class AudioPlayer: NSObject { let session = AVAudioSession.sharedInstance() let playbackState = CurrentValueSubject(PlaybackState.unknown) - - // MARK: - singleton - public static let shared = AudioPlayer() let currentTimeSubject = CurrentValueSubject(0) - private override init() { + override init() { super.init() addObserver() } } -extension AudioPlayer { +extension AudioPlaybackService { func playAudio(audioAttachment: Attachment) { guard let url = URL(string: audioAttachment.url) else { return @@ -48,7 +46,7 @@ extension AudioPlayer { return } - pushWillPlayAudioNotification() + notifyWillPlayAudioNotification() if audioAttachment == attachment { if self.playbackState.value == .stopped { self.seekToTime(time: .zero) @@ -129,14 +127,14 @@ extension AudioPlayer { .store(in: &disposeBag) } - func pushWillPlayAudioNotification() { - NotificationCenter.default.post(name: AudioPlayer.appWillPlayAudioNotification, object: nil) + func notifyWillPlayAudioNotification() { + NotificationCenter.default.post(name: AudioPlaybackService.appWillPlayAudioNotification, object: nil) } func isPlaying() -> Bool { return playbackState.value == .readyToPlay || playbackState.value == .playing } func resume() { - pushWillPlayAudioNotification() + notifyWillPlayAudioNotification() player.play() playbackState.value = .playing } @@ -154,3 +152,10 @@ extension AudioPlayer { player.seek(to: CMTimeMake(value:Int64(time), timescale: 1)) } } + +extension AudioPlaybackService { + func viewDidDisappear(from viewController: UIViewController?) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + pause() + } +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 49ac3b0df..24ea6e6ce 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -91,7 +91,7 @@ extension VideoPlaybackService { } .store(in: &disposeBag) - NotificationCenter.default.publisher(for: AudioPlayer.appWillPlayAudioNotification) + NotificationCenter.default.publisher(for: AudioPlaybackService.appWillPlayAudioNotification) .sink { [weak self] _ in guard let self = self else { return } self.pauseWhenPlayAudio() diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 30069ec30..c5330fc0d 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -28,6 +28,7 @@ class AppContext: ObservableObject { private var documentStoreSubscription: AnyCancellable! let videoPlaybackService = VideoPlaybackService() + let audioPlaybackService = AudioPlaybackService() let overrideTraitCollection = CurrentValueSubject(nil) From 6b5bb4f178d3e22590fee1a12457db9d8d97f391 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 14:08:00 +0800 Subject: [PATCH 076/400] chore: make the database request Fail-Fast --- .../Service/APIService/APIService+Reblog.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 5525fe5bf..a0268b345 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -70,7 +70,7 @@ extension APIService { let managedObjectContext = self.backgroundManagedObjectContext return managedObjectContext.performChanges { - let _requestMastodonUser: MastodonUser? = { + guard let requestMastodonUser: MastodonUser = { let request = MastodonUser.sortedFetchRequest request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) request.fetchLimit = 1 @@ -81,8 +81,11 @@ extension APIService { assertionFailure(error.localizedDescription) return nil } - }() - let _oldToot: Toot? = { + }() else { + return + } + + guard let oldToot: Toot = { let request = Toot.sortedFetchRequest request.predicate = Toot.predicate(domain: domain, id: statusID) request.fetchLimit = 1 @@ -94,13 +97,10 @@ extension APIService { assertionFailure(error.localizedDescription) return nil } - }() - - guard let requestMastodonUser = _requestMastodonUser, - let oldToot = _oldToot else { - assertionFailure() + }() else { return } + APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) if boostKind == .undoBoost { oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) From 71c5ca327ae4caa9d3095b62383b698b3abd83f6 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 14:34:10 +0800 Subject: [PATCH 077/400] chore: make fetch free from exception --- Mastodon.xcodeproj/project.pbxproj | 4 ++++ .../Extension/NSManagedObjectContext.swift | 20 +++++++++++++++++++ .../APIService/APIService+Reblog.swift | 14 ++----------- 3 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 Mastodon/Extension/NSManagedObjectContext.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 439b8ba9c..8bb813fe2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -385,6 +386,7 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -1118,6 +1120,7 @@ 2D206B7F25F5F45E00143C56 /* UIImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, + DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, ); path = Extension; sourceTree = ""; @@ -1625,6 +1628,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, + DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, diff --git a/Mastodon/Extension/NSManagedObjectContext.swift b/Mastodon/Extension/NSManagedObjectContext.swift new file mode 100644 index 000000000..9c569a8f4 --- /dev/null +++ b/Mastodon/Extension/NSManagedObjectContext.swift @@ -0,0 +1,20 @@ +// +// NSManagedObjectContext.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +extension NSManagedObjectContext { + func safeFetch(_ request: NSFetchRequest) -> [T] where T : NSFetchRequestResult { + do { + return try fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + } +} diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index a0268b345..3f750954f 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -75,12 +75,7 @@ extension APIService { request.predicate = MastodonUser.predicate(domain: mastodonAuthenticationBox.domain, id: requestMastodonUserID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() else { return } @@ -91,12 +86,7 @@ extension APIService { request.fetchLimit = 1 request.returnsObjectsAsFaults = false request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() else { return } From 6b9ae8d05df30d6b7d8939f4d6855491d7359eb2 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 11 Mar 2021 15:10:41 +0800 Subject: [PATCH 078/400] chore: add mosaicView --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 8 ++ ...Provider+StatusTableViewCellDelegate.swift | 23 +++++- .../View/Container/MosaicPlayerView.swift | 13 ++++ .../Share/View/Container/MosaicView.swift | 74 +++++++++++++++++++ .../TableviewCell/StatusTableViewCell.swift | 1 + 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 Mastodon/Scene/Share/View/Container/MosaicView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 98bfe2074..84aade870 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; + 2D694A7425F9EB4E0038ADDC /* MosaicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -301,6 +302,7 @@ 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; + 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -1177,6 +1179,7 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, + 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */, 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, @@ -1632,6 +1635,7 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + 2D694A7425F9EB4E0038ADDC /* MosaicView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index daaa98523..f742e4731 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -192,6 +192,14 @@ extension StatusSection { let scale: CGFloat = 1.3 return CGSize(width: maxWidth, height: maxWidth * scale) }() + cell.statusView.mosaicPlayerView.mosaicView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.mosaicPlayerView.mosaicView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.mosaicPlayerView.mosaicView.mosaicButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.statusTableViewCell(cell, mosaicView: cell.statusView.mosaicPlayerView.mosaicView, didTapContentWarningVisualEffectView: cell.statusView.mosaicPlayerView.mosaicView.blurVisualEffectView) + } + .store(in: &cell.disposeBag) if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cd4e5160d..db26930c1 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -45,7 +45,28 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { } - + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicView: MosaicView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusSensitive = false + case .toot(_, let attribute): + attribute.isStatusSensitive = false + default: + return + } + + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + UIView.animate(withDuration: 0.33) { + mosaicView.blurVisualEffectView.effect = nil + mosaicView.vibrancyVisualEffectView.alpha = 0.0 + } completion: { _ in + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { guard let diffableDataSource = self.tableViewDiffableDataSource else { return } guard let item = item(for: cell, indexPath: nil) else { return } diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift index e7c478cea..52596162e 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift @@ -15,6 +15,11 @@ final class MosaicPlayerView: UIView { private let touchBlockingView = TouchBlockingView() private var containerHeightLayoutConstraint: NSLayoutConstraint! + let mosaicView: MosaicView = { + let mosaicView = MosaicView() + return mosaicView + }() + let playerViewController = AVPlayerViewController() let gifIndicatorLabel: UILabel = { @@ -38,6 +43,14 @@ final class MosaicPlayerView: UIView { extension MosaicPlayerView { private func _init() { + addSubview(mosaicView) + NSLayoutConstraint.activate([ + mosaicView.topAnchor.constraint(equalTo: topAnchor), + mosaicView.leadingAnchor.constraint(equalTo: leadingAnchor), + mosaicView.trailingAnchor.constraint(equalTo: trailingAnchor), + mosaicView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + container.translatesAutoresizingMaskIntoConstraints = false addSubview(container) containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) diff --git a/Mastodon/Scene/Share/View/Container/MosaicView.swift b/Mastodon/Scene/Share/View/Container/MosaicView.swift new file mode 100644 index 000000000..10adf3ab4 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/MosaicView.swift @@ -0,0 +1,74 @@ +// +// MosaicView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/11. +// + +import Foundation +import UIKit + +class MosaicView: UIView { + static let cornerRadius: CGFloat = 4 + static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) + let blurVisualEffectView = UIVisualEffectView(effect: MosaicView.blurVisualEffect) + let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicView.blurVisualEffect)) + + let mosaicButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = .clear + return button + }() + + let contentWarningLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + label.text = L10n.Common.Controls.Status.mediaContentWarning + label.textAlignment = .center + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension MosaicView { + private func _init() { + translatesAutoresizingMaskIntoConstraints = false + addSubview(mosaicButton) + NSLayoutConstraint.activate([ + mosaicButton.topAnchor.constraint(equalTo: topAnchor), + mosaicButton.trailingAnchor.constraint(equalTo: trailingAnchor), + mosaicButton.bottomAnchor.constraint(equalTo: bottomAnchor), + mosaicButton.leadingAnchor.constraint(equalTo: leadingAnchor), + ]) + // add blur visual effect view in the setup method + blurVisualEffectView.layer.masksToBounds = true + blurVisualEffectView.layer.cornerRadius = MosaicView.cornerRadius + blurVisualEffectView.layer.cornerCurve = .continuous + + vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) + NSLayoutConstraint.activate([ + vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor), + vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor), + vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor), + vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor), + ]) + + contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false + vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel) + NSLayoutConstraint.activate([ + contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor), + contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), + contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), + ]) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 2f4000b95..d1b5a5c25 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -23,6 +23,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicView: MosaicView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) From 1342471c5dc9c4d3ac56006bd241168c1bfd4ff2 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 11 Mar 2021 15:34:30 +0800 Subject: [PATCH 079/400] chore: handle Video&Gif sensitive situation --- .../Diffiable/Section/StatusSection.swift | 2 ++ ...Provider+StatusTableViewCellDelegate.swift | 2 +- .../View/Container/MosaicPlayerView.swift | 16 +++++------ .../Share/View/Container/MosaicView.swift | 27 ++++++++++++++----- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f742e4731..53a2207a0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -192,8 +192,10 @@ extension StatusSection { let scale: CGFloat = 1.3 return CGSize(width: maxWidth, height: maxWidth * scale) }() + cell.statusView.mosaicPlayerView.mosaicView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil cell.statusView.mosaicPlayerView.mosaicView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.mosaicPlayerView.mosaicView.isUserInteractionEnabled = isStatusSensitive cell.statusView.mosaicPlayerView.mosaicView.mosaicButton.publisher(for: .touchUpInside) .sink { [weak cell] _ in guard let cell = cell else { return } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index db26930c1..9e2797f2e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -57,7 +57,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { default: return } - + mosaicView.isUserInteractionEnabled = false var snapshot = diffableDataSource.snapshot() snapshot.reloadItems([item]) UIView.animate(withDuration: 0.33) { diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift index 52596162e..1851154b6 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift @@ -43,14 +43,6 @@ final class MosaicPlayerView: UIView { extension MosaicPlayerView { private func _init() { - addSubview(mosaicView) - NSLayoutConstraint.activate([ - mosaicView.topAnchor.constraint(equalTo: topAnchor), - mosaicView.leadingAnchor.constraint(equalTo: leadingAnchor), - mosaicView.trailingAnchor.constraint(equalTo: trailingAnchor), - mosaicView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - container.translatesAutoresizingMaskIntoConstraints = false addSubview(container) containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) @@ -74,6 +66,14 @@ extension MosaicPlayerView { playerViewController.view.layer.masksToBounds = true playerViewController.view.layer.cornerRadius = MosaicPlayerView.cornerRadius playerViewController.view.layer.cornerCurve = .continuous + + addSubview(mosaicView) + NSLayoutConstraint.activate([ + mosaicView.topAnchor.constraint(equalTo: topAnchor), + mosaicView.leadingAnchor.constraint(equalTo: leadingAnchor), + mosaicView.trailingAnchor.constraint(equalTo: trailingAnchor), + mosaicView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) } } diff --git a/Mastodon/Scene/Share/View/Container/MosaicView.swift b/Mastodon/Scene/Share/View/Container/MosaicView.swift index 10adf3ab4..82049dbb8 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicView.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicView.swift @@ -17,6 +17,7 @@ class MosaicView: UIView { let mosaicButton: UIButton = { let button = UIButton(type: .custom) button.backgroundColor = .clear + button.translatesAutoresizingMaskIntoConstraints = false return button }() @@ -41,14 +42,9 @@ class MosaicView: UIView { extension MosaicView { private func _init() { + backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false - addSubview(mosaicButton) - NSLayoutConstraint.activate([ - mosaicButton.topAnchor.constraint(equalTo: topAnchor), - mosaicButton.trailingAnchor.constraint(equalTo: trailingAnchor), - mosaicButton.bottomAnchor.constraint(equalTo: bottomAnchor), - mosaicButton.leadingAnchor.constraint(equalTo: leadingAnchor), - ]) + // add blur visual effect view in the setup method blurVisualEffectView.layer.masksToBounds = true blurVisualEffectView.layer.cornerRadius = MosaicView.cornerRadius @@ -70,5 +66,22 @@ extension MosaicView { contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), ]) + + blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(blurVisualEffectView) + NSLayoutConstraint.activate([ + blurVisualEffectView.topAnchor.constraint(equalTo: topAnchor), + blurVisualEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + addSubview(mosaicButton) + NSLayoutConstraint.activate([ + mosaicButton.topAnchor.constraint(equalTo: topAnchor), + mosaicButton.trailingAnchor.constraint(equalTo: trailingAnchor), + mosaicButton.bottomAnchor.constraint(equalTo: bottomAnchor), + mosaicButton.leadingAnchor.constraint(equalTo: leadingAnchor), + ]) } } From 97ecbb1bfb3160392f199da158a7b4b30e3ecc45 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 15:41:27 +0800 Subject: [PATCH 080/400] feat: add compose scene --- Localization/app.json | 6 ++ Mastodon.xcodeproj/project.pbxproj | 63 +++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ Mastodon/Coordinator/SceneCoordinator.swift | 7 ++ .../Diffiable/Item/ComposeStatusItem.swift | 34 ++++++ .../Section/ComposeStatusSection.swift | 13 +++ Mastodon/Generated/Strings.swift | 8 ++ .../Resources/en.lproj/Localizable.strings | 2 + .../Scene/Compose/ComposeViewController.swift | 102 ++++++++++++++++++ .../Compose/ComposeViewModel+Diffable.swift | 40 +++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 44 ++++++++ ...oseRepliedToTootContentTableViewCell.swift | 31 ++++++ .../ComposeTootContentTableViewCell.swift | 40 +++++++ .../HomeTimelineViewController.swift | 3 +- README.md | 1 + 15 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 Mastodon/Diffiable/Item/ComposeStatusItem.swift create mode 100644 Mastodon/Diffiable/Section/ComposeStatusSection.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewController.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Compose/ComposeViewModel.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift diff --git a/Localization/app.json b/Localization/app.json index 123655955..ab5b3f659 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -173,6 +173,12 @@ }, "public_timeline": { "title": "Public" + }, + "compose": { + "title": { + "new_toot": "New Toot", + "new_reply": "New Reply" + } } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 69f431909..d999c0e81 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -149,6 +149,11 @@ DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; }; + DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -156,6 +161,10 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -244,6 +253,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -401,6 +411,9 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -408,6 +421,10 @@ DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; + DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; + DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; + DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTootContentTableViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentTableViewCell.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -461,6 +478,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -718,6 +736,7 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, ); path = Section; sourceTree = ""; @@ -765,6 +784,7 @@ DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, + DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, ); path = Item; sourceTree = ""; @@ -996,6 +1016,26 @@ path = ServerRules; sourceTree = ""; }; + DB789A1025F9F29B0071ACA0 /* Compose */ = { + isa = PBXGroup; + children = ( + DB789A2125F9F76D0071ACA0 /* TableViewCell */, + DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, + DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, + ); + path = Compose; + sourceTree = ""; + }; + DB789A2125F9F76D0071ACA0 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1097,6 +1137,7 @@ DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, + DB789A1025F9F29B0071ACA0 /* Compose */, ); path = Scene; sourceTree = ""; @@ -1253,6 +1294,7 @@ DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, + DB6672A225F9FDE500D60309 /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1382,6 +1424,7 @@ DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1576,6 +1619,7 @@ 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, + DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, @@ -1593,6 +1637,7 @@ 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, + DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -1617,6 +1662,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -1641,6 +1687,7 @@ DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, @@ -1705,10 +1752,13 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, + DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2295,6 +2345,14 @@ minimumVersion = 6.1.0; }; }; + DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twitter/TwitterTextEditor.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2337,6 +2395,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB6672A225F9FDE500D60309 /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3183f10d5..21afdd4cd 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -99,6 +99,15 @@ "revision": "dad97167bf1be16aeecd109130900995dd01c515", "version": "2.6.0" } + }, + { + "package": "TwitterTextEditor", + "repositoryURL": "https://github.com/twitter/TwitterTextEditor.git", + "state": { + "branch": null, + "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", + "version": "1.0.0" + } } ] }, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index fa2963386..24d33c83f 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -47,6 +47,9 @@ extension SceneCoordinator { case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) + // compose + case compose(viewModel: ComposeViewModel) + // misc case alertController(alertController: UIAlertController) @@ -190,6 +193,10 @@ private extension SceneCoordinator { let _viewController = MastodonResendEmailViewController() _viewController.viewModel = viewModel viewController = _viewController + case .compose(let viewModel): + let _viewController = ComposeViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift new file mode 100644 index 000000000..35977f3d4 --- /dev/null +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -0,0 +1,34 @@ +// +// ComposeStatusItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation +import CoreData + +enum ComposeStatusItem { + case replyTo(tootObjectID: NSManagedObjectID) + case toot(attribute: InputAttribute) +} + +extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + class InputAttribute: Hashable { + let hasReplyTo: Bool + + init(hasReplyTo: Bool) { + self.hasReplyTo = hasReplyTo + } + + func hash(into hasher: inout Hasher) { + hasher.combine(hasReplyTo) + } + + static func == (lhs: ComposeStatusItem.InputAttribute, rhs: ComposeStatusItem.InputAttribute) -> Bool { + return lhs.hasReplyTo == rhs.hasReplyTo + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift new file mode 100644 index 000000000..56b006892 --- /dev/null +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -0,0 +1,13 @@ +// +// ComposeStatusSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import Foundation + +enum ComposeStatusSection: Equatable, Hashable { + case repliedTo + case status +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918e..6df84fb7d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -127,6 +127,14 @@ internal enum L10n { } internal enum Scene { + internal enum Compose { + internal enum Title { + /// New Reply + internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") + /// New Toot + internal static let newToot = L10n.tr("Localizable", "Scene.Compose.Title.NewToot") + } + } internal enum ConfirmEmail { /// We just sent an email to %@,\ntap the link to confirm your account. internal static func subtitle(_ p1: Any) -> String { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e274..a83819ddc 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,8 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift new file mode 100644 index 000000000..f5f2746d8 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -0,0 +1,102 @@ +// +// ComposeViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import os.log +import UIKit +import Combine +import TwitterTextEditor + +final class ComposeViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ComposeViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) + tableView.register(ComposeTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeTootContentTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + return tableView + }() + +} + +extension ComposeViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemBackground.color + viewModel.title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.title = title + } + .store(in: &disposeBag) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource(for: tableView) + + + } + +} + +extension ComposeViewController { + + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + dismiss(animated: true, completion: nil) + } + +} + +// MARK: - TextEditorViewTextAttributesDelegate +extension ComposeViewController: TextEditorViewTextAttributesDelegate { + + func textEditorView(_ textEditorView: TextEditorView, updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void) { + // TODO: + } + +} + +// MARK: - UITableViewDelegate +extension ComposeViewController: UITableViewDelegate { + +} + +// MARK: - ComposeViewController +extension ComposeViewController: UIAdaptivePresentationControllerDelegate { + + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return viewModel.shouldDismiss.value + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift new file mode 100644 index 000000000..65a608653 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -0,0 +1,40 @@ +// +// ComposeViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +extension ComposeViewModel { + + func setupDiffableDataSource(for tableView: UITableView) { + diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .replyTo(let tootObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell + // TODO: + return cell + case .toot(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + // TODO: + return cell + } + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.repliedTo, .status]) + switch composeKind { + case .replyToot(let tootObjectID): + snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) + snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: true))], toSection: .status) + case .toot: + snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: false))], toSection: .status) + } + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift new file mode 100644 index 000000000..bcda65879 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -0,0 +1,44 @@ +// +// ComposeViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +final class ComposeViewModel { + + // input + let context: AppContext + let composeKind: ComposeKind + + // output + var diffableDataSource: UITableViewDiffableDataSource! + let title: CurrentValueSubject + let shouldDismiss = CurrentValueSubject(true) + + init( + context: AppContext, + composeKind: ComposeKind + ) { + self.context = context + self.composeKind = composeKind + switch composeKind { + case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) + case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + } + // end init + } + +} + +extension ComposeViewModel { + enum ComposeKind { + case toot + case replyToot(tootObjectID: NSManagedObjectID) + } +} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift new file mode 100644 index 000000000..def777caf --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift @@ -0,0 +1,31 @@ +// +// ComposeRepliedToTootContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeRepliedToTootContentTableViewCell: UITableViewCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeRepliedToTootContentTableViewCell { + + private func _init() { + + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift new file mode 100644 index 000000000..f26b19c62 --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -0,0 +1,40 @@ +// +// ComposeTootContentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeTootContentTableViewCell: UITableViewCell { + + let statusView = StatusView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeTootContentTableViewCell { + + private func _init() { + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor), + statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} + diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index c9498dbea..19e8c3ed4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -166,7 +166,8 @@ extension HomeTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + let composeViewModel = ComposeViewModel(context: context, composeKind: .toot) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { diff --git a/README.md b/README.md index 0847c82e5..53e3bf498 100644 --- a/README.md +++ b/README.md @@ -53,5 +53,6 @@ arch -x86_64 pod install - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) +- [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) ## License From bbdd6926d64cf840e644105662fb2bdd54cb63cf Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 11 Mar 2021 17:24:00 +0800 Subject: [PATCH 081/400] chore: rename MosaicView to MosaicBlurView --- Mastodon.xcodeproj/project.pbxproj | 8 +++--- .../Diffiable/Section/StatusSection.swift | 10 +++---- ...Provider+StatusTableViewCellDelegate.swift | 10 ++++--- ...{MosaicView.swift => MosaicBlurView.swift} | 26 +++++++++---------- .../View/Container/MosaicPlayerView.swift | 16 ++++++------ .../TableviewCell/StatusTableViewCell.swift | 2 +- 6 files changed, 37 insertions(+), 35 deletions(-) rename Mastodon/Scene/Share/View/Container/{MosaicView.swift => MosaicBlurView.swift} (84%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 84aade870..5524314b9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -60,7 +60,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; - 2D694A7425F9EB4E0038ADDC /* MosaicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */; }; + 2D694A7425F9EB4E0038ADDC /* MosaicBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -302,7 +302,7 @@ 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; - 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicView.swift; sourceTree = ""; }; + 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicBlurView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -1179,7 +1179,7 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, - 2D694A7325F9EB4E0038ADDC /* MosaicView.swift */, + 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */, 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, @@ -1635,7 +1635,7 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, - 2D694A7425F9EB4E0038ADDC /* MosaicView.swift in Sources */, + 2D694A7425F9EB4E0038ADDC /* MosaicBlurView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 53a2207a0..31ea87cdf 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -193,13 +193,13 @@ extension StatusSection { return CGSize(width: maxWidth, height: maxWidth * scale) }() - cell.statusView.mosaicPlayerView.mosaicView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.mosaicPlayerView.mosaicView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.mosaicPlayerView.mosaicView.isUserInteractionEnabled = isStatusSensitive - cell.statusView.mosaicPlayerView.mosaicView.mosaicButton.publisher(for: .touchUpInside) + cell.statusView.mosaicPlayerView.mosaicBlurView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.mosaicPlayerView.mosaicBlurView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.mosaicPlayerView.mosaicBlurView.isUserInteractionEnabled = isStatusSensitive + cell.statusView.mosaicPlayerView.mosaicBlurView.tapButton.publisher(for: .touchUpInside) .sink { [weak cell] _ in guard let cell = cell else { return } - cell.delegate?.statusTableViewCell(cell, mosaicView: cell.statusView.mosaicPlayerView.mosaicView, didTapContentWarningVisualEffectView: cell.statusView.mosaicPlayerView.mosaicView.blurVisualEffectView) + cell.delegate?.statusTableViewCell(cell, mosaicBlurView: cell.statusView.mosaicPlayerView.mosaicBlurView, didTapContentWarningVisualEffectView: cell.statusView.mosaicPlayerView.mosaicBlurView.blurVisualEffectView) } .store(in: &cell.disposeBag) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 9e2797f2e..67753e88d 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -45,7 +45,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { } - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicView: MosaicView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { + + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicBlurView: MosaicBlurView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { guard let diffableDataSource = self.tableViewDiffableDataSource else { return } guard let item = item(for: cell, indexPath: nil) else { return } @@ -57,16 +58,17 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { default: return } - mosaicView.isUserInteractionEnabled = false + mosaicBlurView.isUserInteractionEnabled = false var snapshot = diffableDataSource.snapshot() snapshot.reloadItems([item]) UIView.animate(withDuration: 0.33) { - mosaicView.blurVisualEffectView.effect = nil - mosaicView.vibrancyVisualEffectView.alpha = 0.0 + mosaicBlurView.blurVisualEffectView.effect = nil + mosaicBlurView.vibrancyVisualEffectView.alpha = 0.0 } completion: { _ in diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) } } + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { guard let diffableDataSource = self.tableViewDiffableDataSource else { return } guard let item = item(for: cell, indexPath: nil) else { return } diff --git a/Mastodon/Scene/Share/View/Container/MosaicView.swift b/Mastodon/Scene/Share/View/Container/MosaicBlurView.swift similarity index 84% rename from Mastodon/Scene/Share/View/Container/MosaicView.swift rename to Mastodon/Scene/Share/View/Container/MosaicBlurView.swift index 82049dbb8..72f03ab67 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicView.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicBlurView.swift @@ -8,13 +8,13 @@ import Foundation import UIKit -class MosaicView: UIView { +class MosaicBlurView: UIView { static let cornerRadius: CGFloat = 4 static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - let blurVisualEffectView = UIVisualEffectView(effect: MosaicView.blurVisualEffect) - let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicView.blurVisualEffect)) + let blurVisualEffectView = UIVisualEffectView(effect: MosaicBlurView.blurVisualEffect) + let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicBlurView.blurVisualEffect)) - let mosaicButton: UIButton = { + let tapButton: UIButton = { let button = UIButton(type: .custom) button.backgroundColor = .clear button.translatesAutoresizingMaskIntoConstraints = false @@ -40,14 +40,14 @@ class MosaicView: UIView { } } -extension MosaicView { +extension MosaicBlurView { private func _init() { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false // add blur visual effect view in the setup method blurVisualEffectView.layer.masksToBounds = true - blurVisualEffectView.layer.cornerRadius = MosaicView.cornerRadius + blurVisualEffectView.layer.cornerRadius = MosaicBlurView.cornerRadius blurVisualEffectView.layer.cornerCurve = .continuous vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false @@ -66,7 +66,7 @@ extension MosaicView { contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), ]) - + blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false addSubview(blurVisualEffectView) NSLayoutConstraint.activate([ @@ -75,13 +75,13 @@ extension MosaicView { blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - - addSubview(mosaicButton) + + addSubview(tapButton) NSLayoutConstraint.activate([ - mosaicButton.topAnchor.constraint(equalTo: topAnchor), - mosaicButton.trailingAnchor.constraint(equalTo: trailingAnchor), - mosaicButton.bottomAnchor.constraint(equalTo: bottomAnchor), - mosaicButton.leadingAnchor.constraint(equalTo: leadingAnchor), + tapButton.topAnchor.constraint(equalTo: topAnchor), + tapButton.trailingAnchor.constraint(equalTo: trailingAnchor), + tapButton.bottomAnchor.constraint(equalTo: bottomAnchor), + tapButton.leadingAnchor.constraint(equalTo: leadingAnchor), ]) } } diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift index 1851154b6..49ccca5b5 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift @@ -15,9 +15,9 @@ final class MosaicPlayerView: UIView { private let touchBlockingView = TouchBlockingView() private var containerHeightLayoutConstraint: NSLayoutConstraint! - let mosaicView: MosaicView = { - let mosaicView = MosaicView() - return mosaicView + let mosaicBlurView: MosaicBlurView = { + let mosaicBlurView = MosaicBlurView() + return mosaicBlurView }() let playerViewController = AVPlayerViewController() @@ -67,12 +67,12 @@ extension MosaicPlayerView { playerViewController.view.layer.cornerRadius = MosaicPlayerView.cornerRadius playerViewController.view.layer.cornerCurve = .continuous - addSubview(mosaicView) + addSubview(mosaicBlurView) NSLayoutConstraint.activate([ - mosaicView.topAnchor.constraint(equalTo: topAnchor), - mosaicView.leadingAnchor.constraint(equalTo: leadingAnchor), - mosaicView.trailingAnchor.constraint(equalTo: trailingAnchor), - mosaicView.bottomAnchor.constraint(equalTo: bottomAnchor) + mosaicBlurView.topAnchor.constraint(equalTo: topAnchor), + mosaicBlurView.leadingAnchor.constraint(equalTo: leadingAnchor), + mosaicBlurView.trailingAnchor.constraint(equalTo: trailingAnchor), + mosaicBlurView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d1b5a5c25..b6af98265 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -23,7 +23,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicView: MosaicView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicBlurView: MosaicBlurView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) From 19a14b7761d6fa868a1d34a31b82e275202f87f0 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 19:06:15 +0800 Subject: [PATCH 082/400] chore: patch for delegate chain --- Mastodon.xcodeproj/project.pbxproj | 16 +++---- .../Diffiable/Section/StatusSection.swift | 18 +++---- ...Provider+StatusTableViewCellDelegate.swift | 46 +++++++++--------- ...erView.swift => PlayerContainerView.swift} | 41 +++++++++++----- .../ContentWarningOverlayView.swift} | 47 ++++++++++--------- .../Scene/Share/View/Content/StatusView.swift | 16 +++++-- .../TableviewCell/StatusTableViewCell.swift | 10 ++-- 7 files changed, 111 insertions(+), 83 deletions(-) rename Mastodon/Scene/Share/View/Container/{MosaicPlayerView.swift => PlayerContainerView.swift} (76%) rename Mastodon/Scene/Share/View/{Container/MosaicBlurView.swift => Content/ContentWarningOverlayView.swift} (69%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5524314b9..368a15f32 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -60,7 +60,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; - 2D694A7425F9EB4E0038ADDC /* MosaicBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */; }; + 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; @@ -94,7 +94,7 @@ 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; - 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */; }; + 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; }; 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; @@ -302,7 +302,7 @@ 2D61335725C188A000CAE157 /* APIService+Persist+Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Timeline.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; - 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicBlurView.swift; sourceTree = ""; }; + 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; @@ -339,7 +339,7 @@ 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; - 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MosaicPlayerView.swift; sourceTree = ""; }; + 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; }; 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; @@ -578,6 +578,7 @@ isa = PBXGroup; children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, + 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, ); path = Content; sourceTree = ""; @@ -1179,9 +1180,8 @@ isa = PBXGroup; children = ( DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, - 2D694A7325F9EB4E0038ADDC /* MosaicBlurView.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, - 5DF1057825F88A1D00D6C0D4 /* MosaicPlayerView.swift */, + 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; @@ -1635,7 +1635,7 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, - 2D694A7425F9EB4E0038ADDC /* MosaicBlurView.swift in Sources */, + 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, @@ -1711,7 +1711,7 @@ 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, - 5DF1057925F88A1D00D6C0D4 /* MosaicPlayerView.swift in Sources */, + 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 31ea87cdf..3fc1d28a4 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -193,21 +193,15 @@ extension StatusSection { return CGSize(width: maxWidth, height: maxWidth * scale) }() - cell.statusView.mosaicPlayerView.mosaicBlurView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.mosaicPlayerView.mosaicBlurView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.mosaicPlayerView.mosaicBlurView.isUserInteractionEnabled = isStatusSensitive - cell.statusView.mosaicPlayerView.mosaicBlurView.tapButton.publisher(for: .touchUpInside) - .sink { [weak cell] _ in - guard let cell = cell else { return } - cell.delegate?.statusTableViewCell(cell, mosaicBlurView: cell.statusView.mosaicPlayerView.mosaicBlurView, didTapContentWarningVisualEffectView: cell.statusView.mosaicPlayerView.mosaicBlurView.blurVisualEffectView) - } - .store(in: &cell.disposeBag) + cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { let parent = cell.delegate?.parent() - let mosaicPlayerView = cell.statusView.mosaicPlayerView + let mosaicPlayerView = cell.statusView.playerContainerView let playerViewController = mosaicPlayerView.setupPlayer( aspectRatio: videoPlayerViewModel.videoSize, maxSize: playerViewMaxSize, @@ -221,8 +215,8 @@ extension StatusSection { mosaicPlayerView.isHidden = false } else { - cell.statusView.mosaicPlayerView.playerViewController.player?.pause() - cell.statusView.mosaicPlayerView.playerViewController.player = nil + cell.statusView.playerContainerView.playerViewController.player?.pause() + cell.statusView.playerContainerView.playerViewController.player = nil } // set poll let poll = (toot.reblog ?? toot).poll diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 67753e88d..cebc845a3 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -46,29 +46,6 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicBlurView: MosaicBlurView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .toot(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - mosaicBlurView.isUserInteractionEnabled = false - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - mosaicBlurView.blurVisualEffectView.effect = nil - mosaicBlurView.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } - } - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { guard let diffableDataSource = self.tableViewDiffableDataSource else { return } guard let item = item(for: cell, indexPath: nil) else { return } @@ -92,6 +69,29 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + guard let diffableDataSource = self.tableViewDiffableDataSource else { return } + guard let item = item(for: cell, indexPath: nil) else { return } + + switch item { + case .homeTimelineIndex(_, let attribute): + attribute.isStatusSensitive = false + case .toot(_, let attribute): + attribute.isStatusSensitive = false + default: + return + } + contentWarningOverlayView.isUserInteractionEnabled = false + var snapshot = diffableDataSource.snapshot() + snapshot.reloadItems([item]) + UIView.animate(withDuration: 0.33) { + contentWarningOverlayView.blurVisualEffectView.effect = nil + contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 + } completion: { _ in + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } + } // MARK: - PollTableView diff --git a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift similarity index 76% rename from Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift rename to Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 49ccca5b5..aa60bacda 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicPlayerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -1,5 +1,5 @@ // -// MosaicPlayerView.swift +// PlayerContainerView.swift // Mastodon // // Created by xiaojian sun on 2021/3/10. @@ -8,16 +8,20 @@ import AVKit import UIKit -final class MosaicPlayerView: UIView { +protocol PlayerContainerViewDelegate: class { + func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +} + +final class PlayerContainerView: UIView { static let cornerRadius: CGFloat = 8 private let container = UIView() private let touchBlockingView = TouchBlockingView() private var containerHeightLayoutConstraint: NSLayoutConstraint! - let mosaicBlurView: MosaicBlurView = { - let mosaicBlurView = MosaicBlurView() - return mosaicBlurView + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + return contentWarningOverlayView }() let playerViewController = AVPlayerViewController() @@ -30,6 +34,8 @@ final class MosaicPlayerView: UIView { return label }() + weak var delegate: PlayerContainerViewDelegate? + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -41,7 +47,7 @@ final class MosaicPlayerView: UIView { } } -extension MosaicPlayerView { +extension PlayerContainerView { private func _init() { container.translatesAutoresizingMaskIntoConstraints = false addSubview(container) @@ -64,20 +70,29 @@ extension MosaicPlayerView { // will not influence full-screen playback playerViewController.view.layer.masksToBounds = true - playerViewController.view.layer.cornerRadius = MosaicPlayerView.cornerRadius + playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius playerViewController.view.layer.cornerCurve = .continuous - addSubview(mosaicBlurView) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ - mosaicBlurView.topAnchor.constraint(equalTo: topAnchor), - mosaicBlurView.leadingAnchor.constraint(equalTo: leadingAnchor), - mosaicBlurView.trailingAnchor.constraint(equalTo: trailingAnchor), - mosaicBlurView.bottomAnchor.constraint(equalTo: bottomAnchor) + contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) + + contentWarningOverlayView.delegate = self } } -extension MosaicPlayerView { +// MARK: - ContentWarningOverlayViewDelegate +extension PlayerContainerView: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + +extension PlayerContainerView { func reset() { // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing diff --git a/Mastodon/Scene/Share/View/Container/MosaicBlurView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift similarity index 69% rename from Mastodon/Scene/Share/View/Container/MosaicBlurView.swift rename to Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 72f03ab67..ec2607e25 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicBlurView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -1,25 +1,24 @@ // -// MosaicView.swift +// ContentWarningOverlayView.swift // Mastodon // // Created by sxiaojian on 2021/3/11. // +import os.log import Foundation import UIKit -class MosaicBlurView: UIView { +protocol ContentWarningOverlayViewDelegate: class { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) +} + +class ContentWarningOverlayView: UIView { + static let cornerRadius: CGFloat = 4 static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - let blurVisualEffectView = UIVisualEffectView(effect: MosaicBlurView.blurVisualEffect) - let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicBlurView.blurVisualEffect)) - - let tapButton: UIButton = { - let button = UIButton(type: .custom) - button.backgroundColor = .clear - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() + let blurVisualEffectView = UIVisualEffectView(effect: ContentWarningOverlayView.blurVisualEffect) + let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: ContentWarningOverlayView.blurVisualEffect)) let contentWarningLabel: UILabel = { let label = UILabel() @@ -28,6 +27,10 @@ class MosaicBlurView: UIView { label.textAlignment = .center return label }() + + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + weak var delegate: ContentWarningOverlayViewDelegate? override init(frame: CGRect) { super.init(frame: frame) @@ -40,14 +43,14 @@ class MosaicBlurView: UIView { } } -extension MosaicBlurView { +extension ContentWarningOverlayView { private func _init() { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false // add blur visual effect view in the setup method blurVisualEffectView.layer.masksToBounds = true - blurVisualEffectView.layer.cornerRadius = MosaicBlurView.cornerRadius + blurVisualEffectView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius blurVisualEffectView.layer.cornerCurve = .continuous vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false @@ -75,13 +78,15 @@ extension MosaicBlurView { blurVisualEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), blurVisualEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - - addSubview(tapButton) - NSLayoutConstraint.activate([ - tapButton.topAnchor.constraint(equalTo: topAnchor), - tapButton.trailingAnchor.constraint(equalTo: trailingAnchor), - tapButton.bottomAnchor.constraint(equalTo: bottomAnchor), - tapButton.leadingAnchor.constraint(equalTo: leadingAnchor), - ]) + + tapGestureRecognizer.addTarget(self, action: #selector(ContentWarningOverlayView.tapGestureRecognizerHandler(_:))) + addGestureRecognizer(tapGestureRecognizer) + } +} + +extension ContentWarningOverlayView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.contentWarningOverlayViewDidPressed(self) } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index ad7734780..4f6f5d43f 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -13,6 +13,7 @@ import AlamofireImage protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) } @@ -156,7 +157,7 @@ final class StatusView: UIView { return imageView }() - let mosaicPlayerView = MosaicPlayerView() + let playerContainerView = PlayerContainerView() let audioView: AudioContainerView = { let audioView = AudioContainerView() @@ -353,7 +354,7 @@ extension StatusView { audioView.heightAnchor.constraint(equalToConstant: 44) ]) // video gif - statusContainerStackView.addArrangedSubview(mosaicPlayerView) + statusContainerStackView.addArrangedSubview(playerContainerView) // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) @@ -364,12 +365,14 @@ extension StatusView { pollTableView.isHidden = true pollStatusStackView.isHidden = true audioView.isHidden = true - mosaicPlayerView.isHidden = true + playerContainerView.isHidden = true contentWarningBlurContentImageView.isHidden = true statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + playerContainerView.delegate = self + contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } @@ -420,6 +423,13 @@ extension StatusView { } +// MARK: - PlayerContainerViewDelegate +extension StatusView: PlayerContainerViewDelegate { + func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.statusView(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + // MARK: - AvatarConfigurableView extension StatusView: AvatarConfigurableView { static var configurableAvatarImageSize: CGSize { return Self.avatarImageSize } diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index b6af98265..e8d986bd7 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -23,8 +23,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicBlurView: MosaicBlurView, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -55,8 +55,8 @@ final class StatusTableViewCell: UITableViewCell { statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() statusView.pollTableView.dataSource = nil - statusView.mosaicPlayerView.reset() - statusView.mosaicPlayerView.isHidden = true + statusView.playerContainerView.reset() + statusView.playerContainerView.isHidden = true disposeBag.removeAll() observations.removeAll() } @@ -198,6 +198,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) } + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.statusTableViewCell(self, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } From 03496a4e97866c4d30e8585bcbff0bd73788ae4b Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 19:23:44 +0800 Subject: [PATCH 083/400] chore: add missing renaming --- Mastodon/Diffiable/Section/StatusSection.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3fc1d28a4..00ca16cc5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -201,8 +201,8 @@ extension StatusSection { let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { let parent = cell.delegate?.parent() - let mosaicPlayerView = cell.statusView.playerContainerView - let playerViewController = mosaicPlayerView.setupPlayer( + let playerContainerView = cell.statusView.playerContainerView + let playerViewController = playerContainerView.setupPlayer( aspectRatio: videoPlayerViewModel.videoSize, maxSize: playerViewMaxSize, parent: parent @@ -211,8 +211,8 @@ extension StatusSection { playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif - mosaicPlayerView.gifIndicatorLabel.isHidden = videoPlayerViewModel.videoKind != .gif - mosaicPlayerView.isHidden = false + playerContainerView.gifIndicatorLabel.isHidden = videoPlayerViewModel.videoKind != .gif + playerContainerView.isHidden = false } else { cell.statusView.playerContainerView.playerViewController.player?.pause() From 2b2759c2ccd537700cc9cce81a6007d80ea5f09f Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 11 Mar 2021 19:26:10 +0800 Subject: [PATCH 084/400] feat: let text editor become first responder when compose scene appear --- .../Diffiable/Item/ComposeStatusItem.swift | 20 +------- .../Scene/Compose/ComposeViewController.swift | 30 +++++++++++ .../Compose/ComposeViewModel+Diffable.swift | 4 +- .../ComposeTootContentTableViewCell.swift | 51 +++++++++++++++++-- .../TableviewCell/StatusTableViewCell.swift | 1 - 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 35977f3d4..2e125f636 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -10,25 +10,7 @@ import CoreData enum ComposeStatusItem { case replyTo(tootObjectID: NSManagedObjectID) - case toot(attribute: InputAttribute) + case toot(replyToTootObjectID: NSManagedObjectID?) } extension ComposeStatusItem: Hashable { } - -extension ComposeStatusItem { - class InputAttribute: Hashable { - let hasReplyTo: Bool - - init(hasReplyTo: Bool) { - self.hasReplyTo = hasReplyTo - } - - func hash(into hasher: inout Hasher) { - hasher.combine(hasReplyTo) - } - - static func == (lhs: ComposeStatusItem.InputAttribute, rhs: ComposeStatusItem.InputAttribute) -> Bool { - return lhs.hasReplyTo == rhs.hasReplyTo - } - } -} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index f5f2746d8..adab1dd91 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -59,6 +59,36 @@ extension ComposeViewController { } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Fix AutoLayout conflict issue + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.markTextViewEditorBecomeFirstResponser() + } + } + +} + +extension ComposeViewController { + private func markTextViewEditorBecomeFirstResponser() { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .toot: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { + continue + } + cell.textEditorView.isEditing = true + return + default: + continue + } + } + } } extension ComposeViewController { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 65a608653..772bb97b6 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -30,9 +30,9 @@ extension ComposeViewModel { switch composeKind { case .replyToot(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: true))], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID)], toSection: .status) case .toot: - snapshot.appendItems([.toot(attribute: ComposeStatusItem.InputAttribute(hasReplyTo: false))], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: nil)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index f26b19c62..6e7a2058c 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -6,10 +6,18 @@ // import UIKit +import TwitterTextEditor final class ComposeTootContentTableViewCell: UITableViewCell { let statusView = StatusView() + let textEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.font = .preferredFont(forTextStyle: .body) +// textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + return textEditorView + }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -26,15 +34,50 @@ final class ComposeTootContentTableViewCell: UITableViewCell { extension ComposeTootContentTableViewCell { private func _init() { + selectionStyle = .none + statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor), - statusView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - statusView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - statusView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), ]) + statusView.statusContainerStackView.isHidden = true + statusView.actionToolbarContainer.isHidden = true + + textEditorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textEditorView) + NSLayoutConstraint.activate([ + textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), + textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + + // let containerStackView = UIStackView() + // containerStackView.axis = .vertical + // containerStackView.spacing = 8 + // containerStackView.translatesAutoresizingMaskIntoConstraints = false + // contentView.addSubview(containerStackView) + // NSLayoutConstraint.activate([ + // containerStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + // containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + // containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + // contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 20), + // ]) + + // TODO: + } + + override func didMoveToWindow() { + super.didMoveToWindow() + } } +extension ComposeTootContentTableViewCell { + +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 2f4000b95..230fe3dcd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -20,7 +20,6 @@ protocol StatusTableViewCellDelegate: class { var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) From 2e31280819ffdc8b381247cc893cd204cf8d5763 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 12 Mar 2021 12:17:07 +0800 Subject: [PATCH 085/400] chore: extract the common blur effect part from MosaicImageViewContainer --- .../Diffiable/Section/StatusSection.swift | 6 +- ...Provider+StatusTableViewCellDelegate.swift | 28 ++---- .../Container/MosaicImageViewContainer.swift | 87 +++++++------------ .../TableviewCell/StatusTableViewCell.swift | 6 +- 4 files changed, 46 insertions(+), 81 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 00ca16cc5..ef3134773 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -170,8 +170,8 @@ extension StatusSection { } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty let isStatusSensitive = statusItemAttribute.isStatusSensitive - cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil - cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { @@ -193,7 +193,7 @@ extension StatusSection { return CGSize(width: maxWidth, height: maxWidth * scale) }() - cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? MosaicImageViewContainer.blurVisualEffect : nil + cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index cebc845a3..cce031c89 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -46,30 +46,16 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .toot(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - cell.statusView.statusMosaicImageViewContainer.blurVisualEffectView.effect = nil - cell.statusView.statusMosaicImageViewContainer.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + contentWarningOverlayView.isUserInteractionEnabled = false + statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { guard let diffableDataSource = self.tableViewDiffableDataSource else { return } guard let item = item(for: cell, indexPath: nil) else { return } diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 5240d4e2c..8e6884463 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -15,15 +15,12 @@ protocol MosaicImageViewContainerPresentable: class { protocol MosaicImageViewContainerDelegate: class { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) } final class MosaicImageViewContainer: UIView { - static let cornerRadius: CGFloat = 4 - static let blurVisualEffect = UIBlurEffect(style: .systemUltraThinMaterial) - weak var delegate: MosaicImageViewContainerDelegate? let container = UIStackView() @@ -37,14 +34,10 @@ final class MosaicImageViewContainer: UIView { } } } - let blurVisualEffectView = UIVisualEffectView(effect: MosaicImageViewContainer.blurVisualEffect) - let vibrancyVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: MosaicImageViewContainer.blurVisualEffect)) - let contentWarningLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) - label.text = L10n.Common.Controls.Status.mediaContentWarning - label.textAlignment = .center - return label + + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + return contentWarningOverlayView }() private var containerHeightLayoutConstraint: NSLayoutConstraint! @@ -61,9 +54,16 @@ final class MosaicImageViewContainer: UIView { } +extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + self.delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } +} + extension MosaicImageViewContainer { private func _init() { + contentWarningOverlayView.delegate = self container.translatesAutoresizingMaskIntoConstraints = false container.axis = .horizontal container.distribution = .fillEqually @@ -77,32 +77,13 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint ]) - // add blur visual effect view in the setup method - blurVisualEffectView.layer.masksToBounds = true - blurVisualEffectView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius - blurVisualEffectView.layer.cornerCurve = .continuous - - vibrancyVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - blurVisualEffectView.contentView.addSubview(vibrancyVisualEffectView) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ - vibrancyVisualEffectView.topAnchor.constraint(equalTo: blurVisualEffectView.topAnchor), - vibrancyVisualEffectView.leadingAnchor.constraint(equalTo: blurVisualEffectView.leadingAnchor), - vibrancyVisualEffectView.trailingAnchor.constraint(equalTo: blurVisualEffectView.trailingAnchor), - vibrancyVisualEffectView.bottomAnchor.constraint(equalTo: blurVisualEffectView.bottomAnchor), + contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor), ]) - - contentWarningLabel.translatesAutoresizingMaskIntoConstraints = false - vibrancyVisualEffectView.contentView.addSubview(contentWarningLabel) - NSLayoutConstraint.activate([ - contentWarningLabel.leadingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.leadingAnchor), - contentWarningLabel.trailingAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.layoutMarginsGuide.trailingAnchor), - contentWarningLabel.centerYAnchor.constraint(equalTo: vibrancyVisualEffectView.contentView.centerYAnchor), - ]) - - blurVisualEffectView.isUserInteractionEnabled = true - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.visualEffectViewTapGestureRecognizerHandler(_:))) - blurVisualEffectView.addGestureRecognizer(tapGesture) } } @@ -117,9 +98,9 @@ extension MosaicImageViewContainer { container.subviews.forEach { subview in subview.removeFromSuperview() } - blurVisualEffectView.removeFromSuperview() - blurVisualEffectView.effect = MosaicImageViewContainer.blurVisualEffect - vibrancyVisualEffectView.alpha = 1.0 + contentWarningOverlayView.removeFromSuperview() + contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect + contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 imageViews = [] container.spacing = 1 @@ -140,7 +121,7 @@ extension MosaicImageViewContainer { let imageView = UIImageView() imageViews.append(imageView) imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius + imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius imageView.layer.cornerCurve = .continuous imageView.contentMode = .scaleAspectFill @@ -155,13 +136,12 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true - blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - addSubview(blurVisualEffectView) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ - blurVisualEffectView.topAnchor.constraint(equalTo: imageView.topAnchor), - blurVisualEffectView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), - blurVisualEffectView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), - blurVisualEffectView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) return imageView @@ -193,7 +173,7 @@ extension MosaicImageViewContainer { self.imageViews.append(contentsOf: imageViews) imageViews.forEach { imageView in imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = MosaicImageViewContainer.cornerRadius + imageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius imageView.layer.cornerCurve = .continuous imageView.contentMode = .scaleAspectFill } @@ -242,13 +222,12 @@ extension MosaicImageViewContainer { } } - blurVisualEffectView.translatesAutoresizingMaskIntoConstraints = false - addSubview(blurVisualEffectView) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ - blurVisualEffectView.topAnchor.constraint(equalTo: container.topAnchor), - blurVisualEffectView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - blurVisualEffectView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - blurVisualEffectView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) return imageViews @@ -260,7 +239,7 @@ extension MosaicImageViewContainer { @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.mosaicImageViewContainer(self, didTapContentWarningVisualEffectView: blurVisualEffectView) + delegate?.mosaicImageViewContainer(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } @objc private func photoTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index e8d986bd7..bc301b71c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -22,7 +22,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) @@ -215,8 +215,8 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate { delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) } - func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapContentWarningVisualEffectView visualEffectView: UIVisualEffectView) { - delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, didTapContentWarningVisualEffectView: visualEffectView) + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.statusTableViewCell(self, mosaicImageViewContainer: mosaicImageViewContainer, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } } From d9e24534640233ba93b48003a888f770be66f5b2 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 14:18:07 +0800 Subject: [PATCH 086/400] feat: make text editor automatic grow height during input --- Localization/app.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Item/ComposeStatusItem.swift | 25 ++++++- .../Section/ComposeStatusSection.swift | 75 ++++++++++++++++++- Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 4 + .../Button/disabled.colorset/Contents.json | 6 +- .../Button/normal.colorset/Contents.json | 20 +++++ .../Resources/en.lproj/Localizable.strings | 2 + .../Scene/Compose/ComposeViewController.swift | 26 +++++-- .../Compose/ComposeViewModel+Diffable.swift | 29 +++---- Mastodon/Scene/Compose/ComposeViewModel.swift | 41 +++++++--- .../ComposeTootContentTableViewCell.swift | 31 ++++---- .../View/Button/RoundedEdgesButton.swift | 19 +++++ .../API/Mastodon+API+Statuses.swift | 8 ++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 16 files changed, 246 insertions(+), 50 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json create mode 100644 Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift diff --git a/Localization/app.json b/Localization/app.json index ab5b3f659..8734ea00f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -178,7 +178,9 @@ "title": { "new_toot": "New Toot", "new_reply": "New Reply" - } + }, + "content_input_placeholder": "Type or paste what's on your mind", + "compose_action": "Toot" } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d999c0e81..532ef6cbe 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -464,6 +465,7 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -649,6 +651,7 @@ 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */, DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */, 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */, + DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */, ); path = Button; sourceTree = ""; @@ -1633,6 +1636,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 2e125f636..812a27a6f 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -6,11 +6,34 @@ // import Foundation +import Combine import CoreData enum ComposeStatusItem { case replyTo(tootObjectID: NSManagedObjectID) - case toot(replyToTootObjectID: NSManagedObjectID?) + case toot(replyToTootObjectID: NSManagedObjectID?, attribute: ComposeTootAttribute) } extension ComposeStatusItem: Hashable { } + +extension ComposeStatusItem { + final class ComposeTootAttribute: Equatable, Hashable { + private let id = UUID() + + let avatarURL = CurrentValueSubject(nil) + let displayName = CurrentValueSubject(nil) + let username = CurrentValueSubject(nil) + let composeContent = CurrentValueSubject(nil) + + static func == (lhs: ComposeTootAttribute, rhs: ComposeTootAttribute) -> Bool { + return lhs.avatarURL.value == rhs.avatarURL.value && + lhs.displayName.value == rhs.displayName.value && + lhs.username.value == rhs.username.value && + lhs.composeContent.value == rhs.composeContent.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 56b006892..e1405309b 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -5,9 +5,82 @@ // Created by MainasuK Cirno on 2021-3-11. // -import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status } + +extension ComposeStatusSection { + enum ComposeKind { + case toot + case replyToot(tootObjectID: NSManagedObjectID) + } +} + +extension ComposeStatusSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + composeKind: ComposeKind + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + switch item { + case .replyTo(let tootObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell + // TODO: + return cell + case .toot(let replyToTootObjectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + managedObjectContext.perform { + guard let replyToTootObjectID = replyToTootObjectID, + let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { + cell.statusView.headerContainerStackView.isHidden = true + return + } + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" + } + ComposeStatusSection.configureComposeTootContent(cell: cell, attribute: attribute) + // self size input cell + cell.composeContent + .receive(on: DispatchQueue.main) + .sink { text in + tableView.beginUpdates() + tableView.endUpdates() + } + .store(in: &cell.disposeBag) + return cell + } + } + } +} + +extension ComposeStatusSection { + static func configureComposeTootContent( + cell: ComposeTootContentTableViewCell, + attribute: ComposeStatusItem.ComposeTootAttribute + ) { + attribute.avatarURL + .receive(on: DispatchQueue.main) + .sink { avatarURL in + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) + } + .store(in: &cell.disposeBag) + Publishers.CombineLatest( + attribute.displayName.eraseToAnyPublisher(), + attribute.username.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { displayName, username in + cell.statusView.nameLabel.text = displayName + cell.statusView.usernameLabel.text = username + } + .store(in: &cell.disposeBag) + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f68170460..f573d2d12 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -48,6 +48,7 @@ internal enum Asset { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") internal static let highlight = ColorAsset(name: "Colors/Button/highlight") + internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { internal static let photo = ColorAsset(name: "Colors/Icon/photo") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6df84fb7d..49e4cd7c2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -128,6 +128,10 @@ internal enum L10n { internal enum Scene { internal enum Compose { + /// Toot + internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") + /// Type or paste what's on your mind + internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") internal enum Title { /// New Reply internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index 78cde95fb..bca754614 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" + "blue" : "140", + "green" : "130", + "red" : "110" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json new file mode 100644 index 000000000..d853a71aa --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "217", + "green" : "144", + "red" : "43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a83819ddc..f9a1ffe64 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,6 +34,8 @@ "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.ComposeAction" = "Toot"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index adab1dd91..f183bb255 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -18,6 +18,20 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! + let composeTootBarButtonItem: UIBarButtonItem = { + let button = RoundedEdgesButton(type: .custom) + button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color), for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + button.setTitleColor(.white, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16) + button.adjustsImageWhenHighlighted = false + let barButtonItem = UIBarButtonItem(customView: button) + return barButtonItem + }() + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) @@ -34,7 +48,6 @@ extension ComposeViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemBackground.color viewModel.title .receive(on: DispatchQueue.main) .sink { [weak self] title in @@ -42,7 +55,10 @@ extension ComposeViewController { self.title = title } .store(in: &disposeBag) + view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) + navigationItem.rightBarButtonItem = composeTootBarButtonItem + tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -54,9 +70,7 @@ extension ComposeViewController { ]) tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView) - - + viewModel.setupDiffableDataSource(for: tableView, dependency: self) } override func viewWillAppear(_ animated: Bool) { @@ -111,7 +125,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { // MARK: - UITableViewDelegate extension ComposeViewController: UITableViewDelegate { - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } } // MARK: - ComposeViewController diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 772bb97b6..5c27bf51d 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -9,30 +9,25 @@ import UIKit extension ComposeViewModel { - func setupDiffableDataSource(for tableView: UITableView) { - diffableDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, item -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .replyTo(let tootObjectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell - // TODO: - return cell - case .toot(let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell - // TODO: - return cell - } - } + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency + ) { + diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + composeKind: composeKind + ) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { case .replyToot(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID)], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) case .toot: - snapshot.appendItems([.toot(replyToTootObjectID: nil)], toSection: .status) + snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index bcda65879..7aaadcb70 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -12,18 +12,25 @@ import CoreDataStack final class ComposeViewModel { + var disposeBag = Set() + // input let context: AppContext - let composeKind: ComposeKind + let composeKind: ComposeStatusSection.ComposeKind + let composeTootAttribute = ComposeStatusItem.ComposeTootAttribute() + let composeContent = CurrentValueSubject("") + let activeAuthentication: CurrentValueSubject // output var diffableDataSource: UITableViewDiffableDataSource! + + // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) init( context: AppContext, - composeKind: ComposeKind + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind @@ -31,14 +38,30 @@ final class ComposeViewModel { case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } + self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) // end init + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .assign(to: \.value, on: activeAuthentication) + .store(in: &disposeBag) + + activeAuthentication + .sink { [weak self] mastodonAuthentication in + guard let self = self else { return } + let mastodonUser = mastodonAuthentication?.user + let username = mastodonUser?.username ?? " " + + self.composeTootAttribute.avatarURL.value = mastodonUser?.avatarImageURL() + self.composeTootAttribute.displayName.value = { + guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { + return username + } + return displayName + }() + self.composeTootAttribute.username.value = username + } + .store(in: &disposeBag) } } - -extension ComposeViewModel { - enum ComposeKind { - case toot - case replyToot(tootObjectID: NSManagedObjectID) - } -} diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 6e7a2058c..5a7f311d1 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -6,19 +6,26 @@ // import UIKit +import Combine import TwitterTextEditor final class ComposeTootContentTableViewCell: UITableViewCell { + var disposeBag = Set() + let statusView = StatusView() + let textEditorView: TextEditorView = { let textEditorView = TextEditorView() textEditorView.font = .preferredFont(forTextStyle: .body) -// textEditorView.scrollView.isScrollEnabled = false + textEditorView.scrollView.isScrollEnabled = false textEditorView.isScrollEnabled = false + textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder return textEditorView }() + let composeContent = PassthroughSubject() + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) _init() @@ -56,19 +63,9 @@ extension ComposeTootContentTableViewCell { textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) - // let containerStackView = UIStackView() - // containerStackView.axis = .vertical - // containerStackView.spacing = 8 - // containerStackView.translatesAutoresizingMaskIntoConstraints = false - // contentView.addSubview(containerStackView) - // NSLayoutConstraint.activate([ - // containerStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - // containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - // containerStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - // contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 20), - // ]) - // TODO: + + textEditorView.changeObserver = self } override func didMoveToWindow() { @@ -81,3 +78,11 @@ extension ComposeTootContentTableViewCell { extension ComposeTootContentTableViewCell { } + +// MARK: - UITextViewDelegate +extension ComposeTootContentTableViewCell: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + guard changeResult.isTextChanged else { return } + composeContent.send(textEditorView.text) + } +} diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift new file mode 100644 index 000000000..a38b711dd --- /dev/null +++ b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift @@ -0,0 +1,19 @@ +// +// RoundedEdgesButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import UIKit + +final class RoundedEdgesButton: UIButton { + + override func layoutSubviews() { + super.layoutSubviews() + + layer.masksToBounds = true + layer.cornerRadius = bounds.height * 0.5 + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift new file mode 100644 index 000000000..f01e6cb47 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -0,0 +1,8 @@ +// +// Mastodon+API+Statuses.swift +// +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import Foundation diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 073d926e9..5443fa22d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -96,6 +96,7 @@ extension Mastodon.API { public enum Onboarding { } public enum Polls { } public enum Timeline { } + public enum Statuses { } public enum Favorites { } } From 1746c1fc777ea6682cc408999b0ff10fcec3b8cf Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 15:23:28 +0800 Subject: [PATCH 087/400] feat: add toolbar for compose scene --- Mastodon.xcodeproj/project.pbxproj | 12 ++ .../Scene/Compose/ComposeViewController.swift | 98 +++++++++++ .../Compose/View/ComposeToolbarView.swift | 154 ++++++++++++++++++ .../MastodonRegisterViewController.swift | 2 +- .../Service/KeyboardResponderService.swift | 11 +- 5 files changed, 269 insertions(+), 8 deletions(-) create mode 100644 Mastodon/Scene/Compose/View/ComposeToolbarView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 532ef6cbe..980271a24 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -203,6 +203,7 @@ DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -466,6 +467,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -984,6 +986,14 @@ path = Preference; sourceTree = ""; }; + DB55D32225FB4D320002F825 /* View */ = { + isa = PBXGroup; + children = ( + DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + ); + path = View; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1022,6 +1032,7 @@ DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( + DB55D32225FB4D320002F825 /* View */, DB789A2125F9F76D0071ACA0 /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, @@ -1731,6 +1742,7 @@ DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, + DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index f183bb255..492a69851 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine import TwitterTextEditor +import KeyboardGuide final class ComposeViewController: UIViewController, NeedsDependency { @@ -41,6 +42,16 @@ final class ComposeViewController: UIViewController, NeedsDependency { return tableView }() + let composeToolbarView: ComposeToolbarView = { + let composeToolbarView = ComposeToolbarView() + return composeToolbarView + }() + var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! + let composeToolbarBackgroundView: UIView = { + let backgroundView = UIView() + return backgroundView + }() + } extension ComposeViewController { @@ -69,6 +80,60 @@ extension ComposeViewController { tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + composeToolbarView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composeToolbarView) + composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) + NSLayoutConstraint.activate([ + composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + composeToolbarViewBottomLayoutConstraint, + composeToolbarView.heightAnchor.constraint(equalToConstant: 44), + ]) + composeToolbarView.preservesSuperviewLayoutMargins = true + composeToolbarView.delegate = self + + // respond scrollView overlap change + view.layoutIfNeeded() + Publishers.CombineLatest3( + KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] isShow, state, endFrame in + guard let self = self else { return } + + guard isShow, state == .dock else { + self.tableView.contentInset.bottom = 0.0 + self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.view.layoutIfNeeded() + } + return + } + + // isShow AND dock state + let contentFrame = self.view.convert(self.tableView.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.tableView.contentInset.bottom = 0.0 + self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.view.layoutIfNeeded() + } + return + } + + self.tableView.contentInset.bottom = padding + self.tableView.verticalScrollIndicatorInsets.bottom = padding + UIView.animate(withDuration: 0.3) { + self.composeToolbarViewBottomLayoutConstraint.constant = padding + self.view.layoutIfNeeded() + } + }) + .store(in: &disposeBag) + tableView.delegate = self viewModel.setupDiffableDataSource(for: tableView, dependency: self) } @@ -123,6 +188,31 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } +// MARK: - ComposeToolbarViewDelegate +extension ComposeViewController: ComposeToolbarViewDelegate { + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + // MARK: - UITableViewDelegate extension ComposeViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { @@ -132,6 +222,14 @@ extension ComposeViewController: UITableViewDelegate { // MARK: - ComposeViewController extension ComposeViewController: UIAdaptivePresentationControllerDelegate { +// func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { +// switch traitCollection.userInterfaceIdiom { +// case .phone: +// return .fullScreen +// default: +// return .pageSheet +// } +// } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss.value diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift new file mode 100644 index 000000000..7b501bf70 --- /dev/null +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -0,0 +1,154 @@ +// +// ComposeToolbarView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-12. +// + +import UIKit + +protocol ComposeToolbarViewDelegate: class { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) +} + +final class ComposeToolbarView: UIView { + + weak var delegate: ComposeToolbarViewDelegate? + + let mediaButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let pollButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "list.bullet", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) + return button + }() + + let emojiButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let contentWarningButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + return button + }() + + let visibilityButton: UIButton = { + let button = UIButton(type: .custom) + button.tintColor = Asset.Colors.Button.normal.color + button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeToolbarView { + private func _init() { + backgroundColor = .secondarySystemBackground + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 0 + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset + ]) + + let buttons = [ + mediaButton, + pollButton, + emojiButton, + contentWarningButton, + visibilityButton, + ] + buttons.forEach { button in + button.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + mediaButton.addTarget(self, action: #selector(ComposeToolbarView.cameraButtonDidPressed(_:)), for: .touchUpInside) + pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside) + contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.locationButtonDidPressed(_:)), for: .touchUpInside) + } +} + + +extension ComposeToolbarView { + + @objc private func cameraButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, cameraButtonDidPressed: sender) + } + + @objc private func gifButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, gifButtonDidPressed: sender) + } + + @objc private func atButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, atButtonDidPressed: sender) + } + + @objc private func topicButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, topicButtonDidPressed: sender) + } + + @objc private func locationButtonDidPressed(_ sender: UIButton) { + delegate?.composeToolbarView(self, locationButtonDidPressed: sender) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeToolbarView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let tootbarView = ComposeToolbarView() + tootbarView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), + tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), + ]) + return tootbarView + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index f078e9b8d..d66f9717c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -351,7 +351,7 @@ extension MastodonRegisterViewController { Publishers.CombineLatest( KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.willEndFrame.eraseToAnyPublisher() + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() ) .sink(receiveValue: { [weak self] state, endFrame in guard let self = self else { return } diff --git a/Mastodon/Service/KeyboardResponderService.swift b/Mastodon/Service/KeyboardResponderService.swift index b21737963..d4bf9b58b 100644 --- a/Mastodon/Service/KeyboardResponderService.swift +++ b/Mastodon/Service/KeyboardResponderService.swift @@ -18,9 +18,8 @@ final class KeyboardResponderService { // output let isShow = CurrentValueSubject(false) let state = CurrentValueSubject(.none) - let didEndFrame = CurrentValueSubject(.zero) - let willEndFrame = CurrentValueSubject(.zero) - + let endFrame = CurrentValueSubject(.zero) + private init() { NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification, object: nil) .sink { notification in @@ -38,15 +37,11 @@ final class KeyboardResponderService { NotificationCenter.default.publisher(for: UIResponder.keyboardDidChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.didEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) NotificationCenter.default.publisher(for: UIResponder.keyboardWillChangeFrameNotification, object: nil) .sink { notification in - guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } - self.willEndFrame.value = endFrame self.updateInternalStatus(notification: notification) } .store(in: &disposeBag) @@ -62,6 +57,8 @@ extension KeyboardResponderService { return } + self.endFrame.value = endFrame + guard isLocal else { self.state.value = .notLocal return From 6b5edff677aa13ebb396ad071184ff80f876a8bb Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 12 Mar 2021 15:41:57 +0800 Subject: [PATCH 088/400] chore: add media type with gif and video --- .../Diffiable/Section/StatusSection.swift | 3 +- Mastodon/Generated/Assets.swift | 1 + .../mediaTypeIndicotor.colorset/Contents.json | 20 ++++++ .../View/Container/PlayerContainerView.swift | 68 +++++++++++++------ 4 files changed, 70 insertions(+), 22 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index ef3134773..aa6a89b91 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -210,8 +210,7 @@ extension StatusSection { playerViewController.delegate = cell.delegate?.playerViewControllerDelegate playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif - - playerContainerView.gifIndicatorLabel.isHidden = videoPlayerViewModel.videoKind != .gif + playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) playerContainerView.isHidden = false } else { diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f68170460..a760c40b8 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -37,6 +37,7 @@ internal enum Asset { internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") } + internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/mediaTypeIndicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json new file mode 100644 index 000000000..e9c583c0d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index aa60bacda..d002e13af 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -26,14 +26,28 @@ final class PlayerContainerView: UIView { let playerViewController = AVPlayerViewController() - let gifIndicatorLabel: UILabel = { + let mediaTypeIndicotorLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 16, weight: .heavy) - label.text = "GIF" + label.font = .systemFont(ofSize: 18, weight: .heavy) label.textColor = .white + label.textAlignment = .right + label.translatesAutoresizingMaskIntoConstraints = false return label }() + let mediaTypeIndicotorView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color + view.translatesAutoresizingMaskIntoConstraints = false + let rect = CGRect(x: 0, y: 0, width: 47, height: 50) + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topLeft], cornerRadii: CGSize(width: 50, height: 50)) + let maskLayer = CAShapeLayer() + maskLayer.frame = rect + maskLayer.path = path.cgPath + view.layer.mask = maskLayer + return view + }() + weak var delegate: PlayerContainerViewDelegate? override init(frame: CGRect) { @@ -60,14 +74,6 @@ extension PlayerContainerView { containerHeightLayoutConstraint, ]) - addSubview(gifIndicatorLabel) - gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - gifIndicatorLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 4), - gifIndicatorLabel.trailingAnchor.constraint(equalTo: trailingAnchor) - ]) - // will not influence full-screen playback playerViewController.view.layer.masksToBounds = true playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius @@ -80,8 +86,24 @@ extension PlayerContainerView { contentWarningOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor), contentWarningOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor) ]) - contentWarningOverlayView.delegate = self + + // mediaType + addSubview(mediaTypeIndicotorView) + NSLayoutConstraint.activate([ + mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: bottomAnchor), + mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: trailingAnchor), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47) + ]) + + mediaTypeIndicotorView.addSubview(mediaTypeIndicotorLabel) + NSLayoutConstraint.activate([ + mediaTypeIndicotorLabel.topAnchor.constraint(equalTo: mediaTypeIndicotorView.topAnchor), + mediaTypeIndicotorLabel.leadingAnchor.constraint(equalTo: mediaTypeIndicotorView.leadingAnchor), + mediaTypeIndicotorLabel.bottomAnchor.constraint(equalTo: mediaTypeIndicotorView.bottomAnchor), + mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: mediaTypeIndicotorLabel.trailingAnchor, constant: 8) + ]) } } @@ -96,8 +118,6 @@ extension PlayerContainerView { func reset() { // note: set playerViewController.player pause() and nil in data source configuration process make reloadData not break playing - gifIndicatorLabel.removeFromSuperview() - playerViewController.willMove(toParent: nil) playerViewController.view.removeFromSuperview() playerViewController.removeFromParent() @@ -137,13 +157,21 @@ extension PlayerContainerView { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true - gifIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false - touchBlockingView.addSubview(gifIndicatorLabel) - NSLayoutConstraint.activate([ - touchBlockingView.trailingAnchor.constraint(equalTo: gifIndicatorLabel.trailingAnchor, constant: 8), - touchBlockingView.bottomAnchor.constraint(equalTo: gifIndicatorLabel.bottomAnchor, constant: 8), - ]) + bringSubviewToFront(mediaTypeIndicotorView) return playerViewController } + + func setMediaKind(kind: VideoPlayerViewModel.Kind) { + switch kind { + case .gif: + mediaTypeIndicotorLabel.text = "GIF" + case .video: + let configuration = UIImage.SymbolConfiguration(font: .systemFont(ofSize: 18, weight: .regular)) + let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.white) + mediaTypeIndicotorLabel.attributedText = NSAttributedString(attachment: attachment) + } + } } From 0c164a170cfb81c94374871c2edcb626a2e21a54 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 12 Mar 2021 15:53:19 +0800 Subject: [PATCH 089/400] chore: use rounded font --- .../Share/View/Container/PlayerContainerView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index d002e13af..3b4acc98c 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -28,7 +28,6 @@ final class PlayerContainerView: UIView { let mediaTypeIndicotorLabel: UILabel = { let label = UILabel() - label.font = .systemFont(ofSize: 18, weight: .heavy) label.textColor = .white label.textAlignment = .right label.translatesAutoresizingMaskIntoConstraints = false @@ -162,12 +161,21 @@ extension PlayerContainerView { return playerViewController } + func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { + let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) + guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } + let roundedFont = UIFont(descriptor: descriptor, size: fontSize) + return roundedFont + } func setMediaKind(kind: VideoPlayerViewModel.Kind) { + let fontSize: CGFloat = 18 + switch kind { case .gif: + mediaTypeIndicotorLabel.font = roundedFont(weight: .heavy, fontSize: fontSize) mediaTypeIndicotorLabel.text = "GIF" case .video: - let configuration = UIImage.SymbolConfiguration(font: .systemFont(ofSize: 18, weight: .regular)) + let configuration = UIImage.SymbolConfiguration(font: roundedFont(weight: .regular, fontSize: fontSize)) let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! let attachment = NSTextAttachment() attachment.image = image.withTintColor(.white) From 36604d150f0de764fda3510a81e6dcba6a6940ee Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 15:57:58 +0800 Subject: [PATCH 090/400] feat: show discard alert when user cancel toot composing --- Localization/app.json | 5 ++ .../Section/ComposeStatusSection.swift | 12 ++++- Mastodon/Generated/Strings.swift | 8 +++ .../Resources/en.lproj/Localizable.strings | 3 ++ .../Scene/Compose/ComposeViewController.swift | 50 +++++++++++++------ Mastodon/Scene/Compose/ComposeViewModel.swift | 19 +++++++ .../Compose/View/ComposeToolbarView.swift | 2 + 7 files changed, 82 insertions(+), 17 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 8734ea00f..17994bb31 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -14,6 +14,10 @@ "vote_failure": { "title": "Vote Failure", "poll_expired": "The poll has expired" + }, + "discard_compose_content": { + "title": "Discard Toot", + "message": "Confirm discard composed toot content." } }, "controls": { @@ -27,6 +31,7 @@ "confirm": "Confirm", "continue": "Continue", "cancel": "Cancel", + "discard": "Discard", "take_photo": "Take photo", "save_photo": "Save photo", "sign_in": "Sign In", diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index e1405309b..be3608ef9 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -46,7 +46,7 @@ extension ComposeStatusSection { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" } - ComposeStatusSection.configureComposeTootContent(cell: cell, attribute: attribute) + ComposeStatusSection.configure(cell: cell, attribute: attribute) // self size input cell cell.composeContent .receive(on: DispatchQueue.main) @@ -62,16 +62,18 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func configureComposeTootContent( + static func configure( cell: ComposeTootContentTableViewCell, attribute: ComposeStatusItem.ComposeTootAttribute ) { + // set avatar attribute.avatarURL .receive(on: DispatchQueue.main) .sink { avatarURL in cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarURL)) } .store(in: &cell.disposeBag) + // set display name and username Publishers.CombineLatest( attribute.displayName.eraseToAnyPublisher(), attribute.username.eraseToAnyPublisher() @@ -82,5 +84,11 @@ extension ComposeStatusSection { cell.statusView.usernameLabel.text = username } .store(in: &cell.disposeBag) + + // bind compose content + cell.composeContent + .map { $0 as String? } + .assign(to: \.value, on: attribute.composeContent) + .store(in: &cell.disposeBag) } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 49e4cd7c2..105e98ef2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -19,6 +19,12 @@ internal enum L10n { /// Please try again later. internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } + internal enum DiscardComposeContent { + /// Confirm discard composed toot content. + internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Message") + /// Discard Toot + internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -46,6 +52,8 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") /// Continue internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") + /// Discard + internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") /// OK diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index f9a1ffe64..5605683de 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,7 @@ "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DiscardComposeContent.Message" = "Confirm discard composed toot content."; +"Common.Alerts.DiscardComposeContent.Title" = "Discard Toot"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -9,6 +11,7 @@ "Common.Controls.Actions.Cancel" = "Cancel"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Edit" = "Edit"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 492a69851..b1a1cf5ba 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -70,7 +70,6 @@ extension ComposeViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) navigationItem.rightBarButtonItem = composeTootBarButtonItem - tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -87,13 +86,17 @@ extension ComposeViewController { composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), composeToolbarViewBottomLayoutConstraint, - composeToolbarView.heightAnchor.constraint(equalToConstant: 44), + composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), ]) composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self + tableView.delegate = self + viewModel.setupDiffableDataSource(for: tableView, dependency: self) + // respond scrollView overlap change view.layoutIfNeeded() + // update layout when keyboard show/dismiss Publishers.CombineLatest3( KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), KeyboardResponderService.shared.state.eraseToAnyPublisher(), @@ -125,8 +128,9 @@ extension ComposeViewController { return } - self.tableView.contentInset.bottom = padding - self.tableView.verticalScrollIndicatorInsets.bottom = padding + // add 16pt margin + self.tableView.contentInset.bottom = padding + 16 + self.tableView.verticalScrollIndicatorInsets.bottom = padding + 16 UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = padding self.view.layoutIfNeeded() @@ -134,8 +138,10 @@ extension ComposeViewController { }) .store(in: &disposeBag) - tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView, dependency: self) + viewModel.isComposeTootBarButtonItemEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeTootBarButtonItem) + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -168,12 +174,32 @@ extension ComposeViewController { } } } + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.DiscardComposeContent.title, + message: L10n.Common.Alerts.DiscardComposeContent.message, + preferredStyle: .alert + ) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + } + alertController.addAction(discardAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + } } extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.shouldDismiss.value else { + showDismissConfirmAlertController() + return + } dismiss(animated: true, completion: nil) } @@ -222,21 +248,15 @@ extension ComposeViewController: UITableViewDelegate { // MARK: - ComposeViewController extension ComposeViewController: UIAdaptivePresentationControllerDelegate { -// func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { -// switch traitCollection.userInterfaceIdiom { -// case .phone: -// return .fullScreen -// default: -// return .pageSheet -// } -// } - + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss.value } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 7aaadcb70..a6228099a 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -27,6 +27,7 @@ final class ComposeViewModel { // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) + let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) init( context: AppContext, @@ -62,6 +63,24 @@ final class ComposeViewModel { self.composeTootAttribute.username.value = username } .store(in: &disposeBag) + + composeTootAttribute.composeContent + .receive(on: DispatchQueue.main) + .map { content in + let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !content.isEmpty + } + .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) + .store(in: &disposeBag) + + composeTootAttribute.composeContent + .receive(on: DispatchQueue.main) + .map { content in + let content = content ?? "" + return content.isEmpty + } + .assign(to: \.value, on: shouldDismiss) + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 7b501bf70..7eb3ae821 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -17,6 +17,8 @@ protocol ComposeToolbarViewDelegate: class { final class ComposeToolbarView: UIView { + static let toolbarHeight: CGFloat = 44 + weak var delegate: ComposeToolbarViewDelegate? let mediaButton: UIButton = { From 92a26b2f7346e37a2646cd678cdb80d6a1f6a7fd Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 12 Mar 2021 19:25:28 +0800 Subject: [PATCH 091/400] feat: [WIP] add mention and hashtag input highlight. Add emoji token replacing logic --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/ComposeStatusSection.swift | 7 +- .../Diffiable/Section/StatusSection.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 115 +++++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 7 +- .../Vender/TwitterTextEditor+String.swift | 54 ++++++++ 6 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Vender/TwitterTextEditor+String.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 980271a24..0d7b441de 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,6 +145,7 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; @@ -409,6 +410,7 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; @@ -672,6 +674,7 @@ children = ( 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, + DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, ); path = Vender; sourceTree = ""; @@ -1671,6 +1674,7 @@ 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, + DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index be3608ef9..5c2124540 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import TwitterTextEditor enum ComposeStatusSection: Equatable, Hashable { case repliedTo @@ -27,9 +28,10 @@ extension ComposeStatusSection { for tableView: UITableView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, - composeKind: ComposeKind + composeKind: ComposeKind, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .replyTo(let tootObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell @@ -47,6 +49,7 @@ extension ComposeStatusSection { cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" } ComposeStatusSection.configure(cell: cell, attribute: attribute) + cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent .receive(on: DispatchQueue.main) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1d0169ab8..517fd5a2e 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -322,7 +322,7 @@ extension StatusSection { cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) } } else { - assertionFailure() + // assertionFailure() cell.pollCountdownSubscription = nil cell.statusView.pollCountdownLabel.text = "-" } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index b1a1cf5ba..22a48fb0b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,7 +9,6 @@ import os.log import UIKit import Combine import TwitterTextEditor -import KeyboardGuide final class ComposeViewController: UIViewController, NeedsDependency { @@ -44,11 +43,13 @@ final class ComposeViewController: UIViewController, NeedsDependency { let composeToolbarView: ComposeToolbarView = { let composeToolbarView = ComposeToolbarView() + composeToolbarView.backgroundColor = .secondarySystemBackground return composeToolbarView }() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeToolbarBackgroundView: UIView = { let backgroundView = UIView() + backgroundView.backgroundColor = .secondarySystemBackground return backgroundView }() @@ -91,8 +92,21 @@ extension ComposeViewController { composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self + composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) + NSLayoutConstraint.activate([ + composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), + composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), + composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), + view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), + ]) + tableView.delegate = self - viewModel.setupDiffableDataSource(for: tableView, dependency: self) + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + textEditorViewTextAttributesDelegate: self + ) // respond scrollView overlap change view.layoutIfNeeded() @@ -208,12 +222,105 @@ extension ComposeViewController { // MARK: - TextEditorViewTextAttributesDelegate extension ComposeViewController: TextEditorViewTextAttributesDelegate { - func textEditorView(_ textEditorView: TextEditorView, updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void) { - // TODO: + func textEditorView( + _ textEditorView: TextEditorView, + updateAttributedString attributedString: NSAttributedString, + completion: @escaping (NSAttributedString?) -> Void + ) { + + DispatchQueue.global().async { + let string = attributedString.string + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) + + let stringRange = NSRange(location: 0, length: string.length) + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") + // not accept :$ to force user input space to make emoji take effect + let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + completion(nil) + return + } + + // set normal apperance + let attributedString = NSMutableAttributedString(attributedString: attributedString) + attributedString.removeAttribute(.suffixedAttachment, range: stringRange) + attributedString.removeAttribute(.underlineStyle, range: stringRange) + attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) + attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + + for match in highlightMatches { + // hashtag + if let name = string.substring(with: match, at: 2) { + let attachment: TextAttributes.SuffixedAttachment? + switch name { + // FIXME: + case "person": + attachment = .init(size: CGSize(width: 20.0, height: 20.0), + attachment: .image(UIImage(systemName: "person")!)) + default: + attachment = nil + } + + if let attachment = attachment { + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + for match in emojiMatches { + if let name = string.substring(with: match, at: 2) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set emoji token invisiable (without upper bounce space) + var attributes = [NSAttributedString.Key: Any]() + attributes[.font] = UIFont.systemFont(ofSize: 0.01) + let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) + attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace) + + // append emoji attachment + let attachment = TextAttributes.SuffixedAttachment( + size: CGSize(width: 20, height: 20), + attachment: .image(UIImage(systemName: "circle")!) + ) + let index = match.range.upperBound - 1 + attributedString.addAttribute( + .suffixedAttachment, + value: attachment, + range: NSRange(location: index, length: 1) + ) + } + } + + completion(attributedString) + } + } } } + + // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 5c27bf51d..0ee3e0b32 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -6,18 +6,21 @@ // import UIKit +import TwitterTextEditor extension ComposeViewModel { func setupDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency + dependency: NeedsDependency, + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate ) { diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: context.managedObjectContext, - composeKind: composeKind + composeKind: composeKind, + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate ) var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Vender/TwitterTextEditor+String.swift b/Mastodon/Vender/TwitterTextEditor+String.swift new file mode 100644 index 000000000..7abdba3a3 --- /dev/null +++ b/Mastodon/Vender/TwitterTextEditor+String.swift @@ -0,0 +1,54 @@ +// +// String.swift +// Example +// +// Copyright 2021 Twitter, Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension String { + @inlinable + var length: Int { + (self as NSString).length + } + + @inlinable + func substring(with range: NSRange) -> String { + (self as NSString).substring(with: range) + } + + func substring(with result: NSTextCheckingResult, at index: Int) -> String? { + guard index < result.numberOfRanges else { + return nil + } + let range = result.range(at: index) + guard range.location != NSNotFound else { + return nil + } + return substring(with: result.range(at: index)) + } + + func firstMatch(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> NSTextCheckingResult? + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return nil + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.firstMatch(in: self, options: [], range: range) + } + + func matches(pattern: String, + options: NSRegularExpression.Options = [], + range: NSRange? = nil) -> [NSTextCheckingResult] + { + guard let regularExpression = try? NSRegularExpression(pattern: pattern, options: options) else { + return [] + } + let range = range ?? NSRange(location: 0, length: length) + return regularExpression.matches(in: self, options: [], range: range) + } +} From 8c48bce627383c405052ebe2066ac5efabcb1cbd Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:42:46 +0800 Subject: [PATCH 092/400] chore: rename toot --- Localization/app.json | 28 +++++++++---------- .../Section/ComposeStatusSection.swift | 6 ++-- .../Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Strings.swift | 26 ++++++++--------- .../Resources/en.lproj/Localizable.strings | 14 +++++----- .../Scene/Compose/ComposeViewController.swift | 5 ++-- .../Compose/ComposeViewModel+Diffable.swift | 4 +-- Mastodon/Scene/Compose/ComposeViewModel.swift | 4 +-- ...meTimelineViewController+DebugAction.swift | 8 +++--- .../HomeTimelineViewController.swift | 2 +- .../TableViewCell/PickServerCell.swift | 4 +-- .../Scene/Share/View/Content/StatusView.swift | 2 +- 12 files changed, 52 insertions(+), 53 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 17994bb31..d521a98da 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -15,9 +15,9 @@ "title": "Vote Failure", "poll_expired": "The poll has expired" }, - "discard_compose_content": { - "title": "Discard Toot", - "message": "Confirm discard composed toot content." + "discard_post_content": { + "title": "Discard Publish", + "message": "Confirm discard composed post content." } }, "controls": { @@ -41,7 +41,7 @@ "open_in_safari": "Open in Safari" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", @@ -76,17 +76,17 @@ }, "server_picker": { "title": "Pick a Server,\nany server.", - "Button": { - "Category": { + "button": { + "category": { "All": "All" }, - "SeeLess": "See Less", - "SeeMore": "See More" + "see_less": "See Less", + "see_more": "See More" }, - "Label": { - "Language": "LANGUAGE", - "Users": "USERS", - "Category": "CATEGORY" + "label": { + "language": "LANGUAGE", + "users": "USERS", + "category": "CATEGORY" }, "input": { "placeholder": "Find a server or join your own..." @@ -181,11 +181,11 @@ }, "compose": { "title": { - "new_toot": "New Toot", + "new_post": "New Post", "new_reply": "New Reply" }, "content_input_placeholder": "Type or paste what's on your mind", - "compose_action": "Toot" + "compose_action": "Publish" } } } \ No newline at end of file diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 5c2124540..ce08fc009 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -18,8 +18,8 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { - case toot - case replyToot(tootObjectID: NSManagedObjectID) + case post + case reply(repliedToStatusObjectID: NSManagedObjectID) } } @@ -33,7 +33,7 @@ extension ComposeStatusSection { ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { - case .replyTo(let tootObjectID): + case .replyTo(let repliedToStatusObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell // TODO: return cell diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 517fd5a2e..3f4a4f366 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -91,7 +91,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) + return L10n.Common.Controls.Status.userReblogged(name) }() // set name username avatar diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 105e98ef2..dda118a04 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -19,11 +19,11 @@ internal enum L10n { /// Please try again later. internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } - internal enum DiscardComposeContent { - /// Confirm discard composed toot content. - internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Message") - /// Discard Toot - internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardComposeContent.Title") + internal enum DiscardPostContent { + /// Confirm discard composed post content. + internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") + /// Discard Publish + internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") } internal enum ServerError { /// Server Error @@ -84,9 +84,9 @@ internal enum L10n { internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// content warning internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// %@ reblogged + internal static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } internal enum Poll { /// Closed @@ -136,15 +136,15 @@ internal enum L10n { internal enum Scene { internal enum Compose { - /// Toot + /// Publish internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") /// Type or paste what's on your mind internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") internal enum Title { + /// New Post + internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") /// New Reply internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") - /// New Toot - internal static let newToot = L10n.tr("Localizable", "Scene.Compose.Title.NewToot") } } internal enum ConfirmEmail { @@ -290,9 +290,9 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") internal enum Button { /// See Less - internal static let seeless = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seeless") + internal static let seeLess = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeLess") /// See More - internal static let seemore = L10n.tr("Localizable", "Scene.ServerPicker.Button.Seemore") + internal static let seeMore = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeMore") internal enum Category { /// All internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 5605683de..c2eeb4916 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,7 +1,7 @@ "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; -"Common.Alerts.DiscardComposeContent.Message" = "Confirm discard composed toot content."; -"Common.Alerts.DiscardComposeContent.Title" = "Discard Toot"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -33,14 +33,14 @@ "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; -"Scene.Compose.ComposeAction" = "Toot"; +"Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; -"Scene.Compose.Title.NewToot" = "New Toot"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; @@ -84,8 +84,8 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; "Scene.ServerPicker.Button.Category.All" = "All"; -"Scene.ServerPicker.Button.Seeless" = "See Less"; -"Scene.ServerPicker.Button.Seemore" = "See More"; +"Scene.ServerPicker.Button.SeeLess" = "See Less"; +"Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 22a48fb0b..729c81e8c 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -191,8 +191,8 @@ extension ComposeViewController { private func showDismissConfirmAlertController() { let alertController = UIAlertController( - title: L10n.Common.Alerts.DiscardComposeContent.title, - message: L10n.Common.Alerts.DiscardComposeContent.message, + title: L10n.Common.Alerts.DiscardPostContent.title, + message: L10n.Common.Alerts.DiscardPostContent.message, preferredStyle: .alert ) let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in @@ -227,7 +227,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void ) { - DispatchQueue.global().async { let string = attributedString.string os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 0ee3e0b32..b175aaca1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -26,10 +26,10 @@ extension ComposeViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { - case .replyToot(let tootObjectID): + case .reply(let tootObjectID): snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) - case .toot: + case .post: snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a6228099a..097de6ae5 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -36,8 +36,8 @@ final class ComposeViewModel { self.context = context self.composeKind = composeKind switch composeKind { - case .toot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newToot) - case .replyToot: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) // end init diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0937e1fb4..08696db9d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -108,8 +108,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.poll != nil + let post = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return post.poll != nil default: return false } @@ -148,8 +148,8 @@ extension HomeTimelineViewController { self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } for objectID in droppingTootObjectIDs { - guard let toot = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } - self.context.apiService.backgroundManagedObjectContext.delete(toot) + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(post) } } .sink { _ in diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 19e8c3ed4..e405a4a97 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -166,7 +166,7 @@ extension HomeTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .toot) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 95a5491cc..5ff83cc70 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -88,8 +88,8 @@ class PickServerCell: UITableViewCell { let expandButton: UIButton = { let button = UIButton(type: .custom) - button.setTitle(L10n.Scene.ServerPicker.Button.seemore, for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeless, for: .selected) + button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) + button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index ad7734780..324da6e26 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -350,7 +350,7 @@ extension StatusView { NSLayoutConstraint.activate([ audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - audioView.heightAnchor.constraint(equalToConstant: 44) + audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) ]) // video gif statusContainerStackView.addArrangedSubview(mosaicPlayerView) From 6882788cccf5ba1187eb95bc429a8cf2f2918940 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:43:15 +0800 Subject: [PATCH 093/400] fix: AutoLayout fail before view appear issue --- Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e832e5a43..d96d17baf 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -116,6 +116,7 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } // MARK: - UIAdaptivePresentationControllerDelegate extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { - return .fullScreen + // make underneath view controller alive to fix layout issue due to view life cycle + return .overFullScreen } } From a6e4b0bfb1cfded31de809aebf2dca8931f5e051 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:44:29 +0800 Subject: [PATCH 094/400] feat: set compose text editor keyboard type to supports @/# glyphs --- .../Compose/TableViewCell/ComposeTootContentTableViewCell.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 5a7f311d1..95e49c63a 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -21,6 +21,7 @@ final class ComposeTootContentTableViewCell: UITableViewCell { textEditorView.scrollView.isScrollEnabled = false textEditorView.isScrollEnabled = false textEditorView.placeholderText = L10n.Scene.Compose.contentInputPlaceholder + textEditorView.keyboardType = .twitter return textEditorView }() From 8eb24871c5d5b6c6cf2bd290652240a017903e00 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 13:44:42 +0800 Subject: [PATCH 095/400] feat: add URL highlight for text editor --- .../Scene/Compose/ComposeViewController.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 729c81e8c..8504ffc5b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -235,6 +235,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") // not accept :$ to force user input space to make emoji take effect let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + // only accept http/https scheme + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") DispatchQueue.main.async { [weak self] in guard let self = self else { @@ -311,6 +313,27 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } } + for match in urlMatches { + if let name = string.substring(with: match, at: 0) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break + } + } + attributedString.addAttributes(attributes, range: match.range) + } + } + completion(attributedString) } } From 9f02197873bf487e97fc1ccdd7a677f6bc8a7f15 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 14:40:10 +0800 Subject: [PATCH 096/400] feat: add custom emojis API endpoint --- Mastodon.xcodeproj/project.pbxproj | 24 ++++++ .../Scene/Compose/ComposeViewController.swift | 2 +- .../APIService/APIService+CustomEmoji.swift | 22 +++++ Mastodon/Service/AuthenticationService.swift | 2 +- .../EmojiService+CustomEmoji+LoadState.swift | 86 +++++++++++++++++++ .../EmojiService+CustomEmoji.swift | 45 ++++++++++ .../Service/EmojiService/EmojiService.swift | 26 ++++++ Mastodon/State/AppContext.swift | 8 +- .../API/Mastodon+API+CustomEmojis.swift | 48 +++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 5 +- 10 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+CustomEmoji.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift create mode 100644 Mastodon/Service/EmojiService/EmojiService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0d7b441de..e75ae17ce 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -141,6 +141,10 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */; }; DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */; }; + DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */; }; + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -407,6 +411,10 @@ DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+MastodonAuthentication.swift"; sourceTree = ""; }; DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; + DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji.swift"; sourceTree = ""; }; + DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji+LoadState.swift"; sourceTree = ""; }; + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -688,6 +696,7 @@ 2D206B8B25F6015000143C56 /* AudioPlayer.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, + DB49A61925FF327D00B98345 /* EmojiService */, ); path = Service; sourceTree = ""; @@ -967,6 +976,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, + DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, ); path = APIService; sourceTree = ""; @@ -981,6 +991,16 @@ path = CoreData; sourceTree = ""; }; + DB49A61925FF327D00B98345 /* EmojiService */ = { + isa = PBXGroup; + children = ( + DB49A61325FF2C5600B98345 /* EmojiService.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */, + ); + path = EmojiService; + sourceTree = ""; + }; DB5086CB25CC0DB400C2C187 /* Preference */ = { isa = PBXGroup; children = ( @@ -1632,6 +1652,7 @@ 2D206B8C25F6015000143C56 /* AudioPlayer.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, + DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, @@ -1664,6 +1685,7 @@ DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, @@ -1688,6 +1710,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -1770,6 +1793,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, + DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 8504ffc5b..7ad5f7174 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -375,7 +375,7 @@ extension ComposeViewController: UITableViewDelegate { } } -// MARK: - ComposeViewController +// MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift new file mode 100644 index 000000000..96dbcb96b --- /dev/null +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -0,0 +1,22 @@ +// +// APIService+CustomEmoji.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func customEmoji(domain: String) -> AnyPublisher, Error> { + return Mastodon.API.CustomEmojis.customEmojis(session: session, domain: domain) + } + +} diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 9fa411f22..89ce7a182 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -12,7 +12,7 @@ import CoreData import CoreDataStack import MastodonSDK -class AuthenticationService: NSObject { +final class AuthenticationService: NSObject { var disposeBag = Set() // input diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift new file mode 100644 index 000000000..3ac6a49a2 --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift @@ -0,0 +1,86 @@ +// +// EmojiService+CustomEmoji+LoadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import GameplayKit + +extension EmojiService.CustomEmoji { + class LoadState: GKState { + weak var viewModel: EmojiService.CustomEmoji? + + init(viewModel: EmojiService.CustomEmoji) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension EmojiService.CustomEmoji.LoadState { + + class Initial: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.context.apiService.customEmoji(domain: viewModel.domain) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to load custom emojis for %s: %s. Retry 10s later", ((#file as NSString).lastPathComponent), #line, #function, viewModel.domain, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %ld custom emojis for %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.count, viewModel.domain) + stateMachine.enter(Finish.self) + viewModel.emojis.value = response.value + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let stateMachine = stateMachine else { return } + + // retry 10s later + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + stateMachine.enter(Loading.self) + } + } + } + + class Finish: EmojiService.CustomEmoji.LoadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // one time task + return false + } + } + +} diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift new file mode 100644 index 000000000..aa253cf9a --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift @@ -0,0 +1,45 @@ +// +// EmojiService+CustomEmoji.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine +import GameplayKit +import MastodonSDK + +extension EmojiService { + final class CustomEmoji { + + var disposeBag = Set() + + // input + let domain: String + let context: AppContext + + // output + private(set) lazy var stateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadState.Initial(viewModel: self), + LoadState.Loading(viewModel: self), + LoadState.Fail(viewModel: self), + LoadState.Finish(viewModel: self), + ]) + stateMachine.enter(LoadState.Initial.self) + return stateMachine + }() + let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) + + init(domain: String, context: AppContext) { + self.domain = domain + self.context = context + + // trigger loading + stateMachine.enter(LoadState.Loading.self) + } + + } +} diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift new file mode 100644 index 000000000..468c2e6dd --- /dev/null +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -0,0 +1,26 @@ +// +// EmojiService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import os.log +import Foundation +import Combine +import MastodonSDK + +final class EmojiService { + + let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + + weak var apiService: APIService? + + // ouput + + + init(apiService: APIService) { + self.apiService = apiService + } +} + diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 30069ec30..bbb1c7952 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -23,12 +23,12 @@ class AppContext: ObservableObject { let apiService: APIService let authenticationService: AuthenticationService + let emojiService: EmojiService + let videoPlaybackService = VideoPlaybackService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! - let videoPlaybackService = VideoPlaybackService() - let overrideTraitCollection = CurrentValueSubject(nil) init() { @@ -48,6 +48,10 @@ class AppContext: ObservableObject { apiService: _apiService ) + emojiService = EmojiService( + apiService: apiService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift new file mode 100644 index 000000000..091e12d11 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+CustomEmojis.swift @@ -0,0 +1,48 @@ +// +// Mastodon+API+CustomEmojis.swift +// +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation +import Combine + +extension Mastodon.API.CustomEmojis { + + static func customEmojisEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("custom_emojis") + } + + /// Custom emoji + /// + /// Returns custom emojis that are available on the server. + /// + /// - Since: 2.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/15 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/custom_emojis/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - Returns: `AnyPublisher` contains [`Emoji`] nested in the response + public static func customEmojis( + session: URLSession, + domain: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: customEmojisEndpointURL(domain: domain), + query: nil, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Emoji].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 5443fa22d..8b952fdb0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -91,13 +91,14 @@ extension Mastodon.API { extension Mastodon.API { public enum Account { } public enum App { } + public enum CustomEmojis { } + public enum Favorites { } public enum Instance { } public enum OAuth { } public enum Onboarding { } public enum Polls { } - public enum Timeline { } public enum Statuses { } - public enum Favorites { } + public enum Timeline { } } extension Mastodon.API { From 20283d187879a428f6ebb11d75fa2f0c2dfc7936 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 15 Mar 2021 15:08:58 +0800 Subject: [PATCH 097/400] chore: change video and audio notification string --- Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift | 2 +- Mastodon/Service/AudioPlaybackService.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift index 3fa241486..d34e73eba 100644 --- a/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/VideoPlayerViewModel.swift @@ -14,7 +14,7 @@ import UIKit final class VideoPlayerViewModel { var disposeBag = Set() - static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.VideoPlayerViewModel.appWillPlayVideo") + static let appWillPlayVideoNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.video-playback-service.appWillPlayVideo") // input let previewImageURL: URL? let videoURL: URL diff --git a/Mastodon/Service/AudioPlaybackService.swift b/Mastodon/Service/AudioPlaybackService.swift index 314f39649..34ceb3bbe 100644 --- a/Mastodon/Service/AudioPlaybackService.swift +++ b/Mastodon/Service/AudioPlaybackService.swift @@ -14,7 +14,7 @@ import os.log final class AudioPlaybackService: NSObject { - static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.AudioPlayer.appWillPlayAudio") + static let appWillPlayAudioNotification = NSNotification.Name(rawValue: "org.joinmastodon.Mastodon.audio-playback-service.appWillPlayAudio") var disposeBag = Set() From 988723691e2b37346df3d5ca4ca061aa25a7d0a4 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 16:28:22 +0800 Subject: [PATCH 098/400] fix: content warning overlay invalid due to cell reuse issue --- Mastodon/Diffiable/Section/StatusSection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f6a4b918b..8f89cdd3f 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -182,6 +182,7 @@ extension StatusSection { let isStatusSensitive = statusItemAttribute.isStatusSensitive cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { From 6a8dee037f17b764ee63756ae1eeff7e760f11ee Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:52:28 +0800 Subject: [PATCH 099/400] fix: media not response for reblog issue --- .../StatusProvider+UITableViewDelegate.swift | 1 + .../PlayerContainerView+MediaTypeIndicotorView.swift | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 3d7074d3f..89ae8e6eb 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -71,6 +71,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { toot(for: cell, indexPath: indexPath) .sink { [weak self] toot in guard let self = self else { return } + let toot = toot?.reblog ?? toot guard let media = (toot?.mediaAttachments ?? Set()).first else { return } guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift new file mode 100644 index 000000000..0accc40b6 --- /dev/null +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -0,0 +1,8 @@ +// +// PlayerContainerView+MediaTypeIndicotorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-15. +// + +import Foundation From 5b45224f7bfabefb1ea564fc8ed5e48cbc083771 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:53:06 +0800 Subject: [PATCH 100/400] feat: make media indicator view hide when playing video --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Section/StatusSection.swift | 17 ++++ ...ContainerView+MediaTypeIndicotorView.swift | 93 ++++++++++++++++++- .../View/Container/PlayerContainerView.swift | 79 ++++++---------- 4 files changed, 139 insertions(+), 54 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d3bea6e88..9859676c1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -400,6 +401,7 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -1190,6 +1192,7 @@ DB9D6C0D25E4F9780051B173 /* MosaicImageViewContainer.swift */, 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */, 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */, + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */, 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */, ); path = Container; @@ -1675,6 +1678,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 8f89cdd3f..fad4898d5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -222,6 +222,23 @@ extension StatusSection { playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) + if videoPlayerViewModel.videoKind == .gif { + playerContainerView.setMediaIndicator(isHidden: false) + } else { + videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in + UIView.animate(withDuration: 0.33) { + switch timeControlStatus { + case .playing: + playerContainerView.setMediaIndicator(isHidden: true) + case .paused, .waitingToPlayAtSpecifiedRate: + playerContainerView.setMediaIndicator(isHidden: false) + @unknown default: + assertionFailure() + } + } + } + .store(in: &cell.disposeBag) + } playerContainerView.isHidden = false } else { diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index 0accc40b6..3bff6ef75 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -5,4 +5,95 @@ // Created by MainasuK Cirno on 2021-3-15. // -import Foundation +import UIKit + +extension PlayerContainerView { + + final class MediaTypeIndicotorView: UIView { + + static let indicatorViewSize = CGSize(width: 47, height: 25) + + let maskLayer = CAShapeLayer() + + let label: UILabel = { + let label = UILabel() + label.textColor = .white + label.textAlignment = .right + label.adjustsFontSizeToFitWidth = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath() + path.move(to: CGPoint(x: bounds.width, y: bounds.height)) + path.addLine(to: CGPoint(x: bounds.width, y: 0)) + path.addLine(to: CGPoint(x: bounds.width * 0.5, y: 0)) + path.addCurve( + to: CGPoint(x: 0, y: bounds.height), + controlPoint1: CGPoint(x: bounds.width * 0.2, y: 0), + controlPoint2: CGPoint(x: 0, y: bounds.height * 0.3) + ) + path.close() + + maskLayer.frame = bounds + maskLayer.path = path.cgPath + layer.mask = maskLayer + } + } + +} + +extension PlayerContainerView.MediaTypeIndicotorView { + + private func _init() { + backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color + layoutMargins = UIEdgeInsets(top: 3, left: 13, bottom: 0, right: 6) + + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + label.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + label.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + private static func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { + let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) + guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } + let roundedFont = UIFont(descriptor: descriptor, size: fontSize) + return roundedFont + } + + func setMediaKind(kind: VideoPlayerViewModel.Kind) { + let fontSize: CGFloat = 18 + + switch kind { + case .gif: + label.font = PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .heavy, fontSize: fontSize) + label.text = "GIF" + case .video: + let configuration = UIImage.SymbolConfiguration(font: PlayerContainerView.MediaTypeIndicotorView.roundedFont(weight: .regular, fontSize: fontSize)) + let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.white) + label.attributedText = NSAttributedString(attachment: attachment) + } + } + +} + + diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3b4acc98c..cabed9560 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -26,26 +26,8 @@ final class PlayerContainerView: UIView { let playerViewController = AVPlayerViewController() - let mediaTypeIndicotorLabel: UILabel = { - let label = UILabel() - label.textColor = .white - label.textAlignment = .right - label.translatesAutoresizingMaskIntoConstraints = false - return label - }() - - let mediaTypeIndicotorView: UIView = { - let view = UIView() - view.backgroundColor = Asset.Colors.Background.mediaTypeIndicotor.color - view.translatesAutoresizingMaskIntoConstraints = false - let rect = CGRect(x: 0, y: 0, width: 47, height: 50) - let path = UIBezierPath(roundedRect: rect, byRoundingCorners: [.topLeft], cornerRadii: CGSize(width: 50, height: 50)) - let maskLayer = CAShapeLayer() - maskLayer.frame = rect - maskLayer.path = path.cgPath - view.layer.mask = maskLayer - return view - }() + let mediaTypeIndicotorView = MediaTypeIndicotorView() + let mediaTypeIndicotorViewInContentWarningOverlay = MediaTypeIndicotorView() weak var delegate: PlayerContainerViewDelegate? @@ -78,6 +60,16 @@ extension PlayerContainerView { playerViewController.view.layer.cornerRadius = PlayerContainerView.cornerRadius playerViewController.view.layer.cornerCurve = .continuous + // mediaType + mediaTypeIndicotorView.translatesAutoresizingMaskIntoConstraints = false + playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) + NSLayoutConstraint.activate([ + mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), + mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + ]) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), @@ -87,21 +79,13 @@ extension PlayerContainerView { ]) contentWarningOverlayView.delegate = self - // mediaType - addSubview(mediaTypeIndicotorView) + mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false + contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) NSLayoutConstraint.activate([ - mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: trailingAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47) - ]) - - mediaTypeIndicotorView.addSubview(mediaTypeIndicotorLabel) - NSLayoutConstraint.activate([ - mediaTypeIndicotorLabel.topAnchor.constraint(equalTo: mediaTypeIndicotorView.topAnchor), - mediaTypeIndicotorLabel.leadingAnchor.constraint(equalTo: mediaTypeIndicotorView.leadingAnchor), - mediaTypeIndicotorLabel.bottomAnchor.constraint(equalTo: mediaTypeIndicotorView.bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: mediaTypeIndicotorLabel.trailingAnchor, constant: 8) + mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), ]) } } @@ -161,25 +145,14 @@ extension PlayerContainerView { return playerViewController } - func roundedFont(weight: UIFont.Weight,fontSize: CGFloat) -> UIFont { - let systemFont = UIFont.systemFont(ofSize: fontSize, weight: weight) - guard let descriptor = systemFont.fontDescriptor.withDesign(.rounded) else { return systemFont } - let roundedFont = UIFont(descriptor: descriptor, size: fontSize) - return roundedFont - } func setMediaKind(kind: VideoPlayerViewModel.Kind) { - let fontSize: CGFloat = 18 - - switch kind { - case .gif: - mediaTypeIndicotorLabel.font = roundedFont(weight: .heavy, fontSize: fontSize) - mediaTypeIndicotorLabel.text = "GIF" - case .video: - let configuration = UIImage.SymbolConfiguration(font: roundedFont(weight: .regular, fontSize: fontSize)) - let image = UIImage(systemName: "video.fill", withConfiguration: configuration)! - let attachment = NSTextAttachment() - attachment.image = image.withTintColor(.white) - mediaTypeIndicotorLabel.attributedText = NSAttributedString(attachment: attachment) - } + mediaTypeIndicotorView.setMediaKind(kind: kind) + mediaTypeIndicotorViewInContentWarningOverlay.setMediaKind(kind: kind) } + + func setMediaIndicator(isHidden: Bool) { + mediaTypeIndicotorView.alpha = isHidden ? 0 : 1 + mediaTypeIndicotorViewInContentWarningOverlay.alpha = isHidden ? 0 : 1 + } + } From b9cfd0d9e8da3b327dc20a967ff10fa2a2ff72fc Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:54:15 +0800 Subject: [PATCH 101/400] chore: remove magic number --- .../Scene/Share/View/Container/PlayerContainerView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index cabed9560..3401bfe9e 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -66,8 +66,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) addSubview(contentWarningOverlayView) @@ -84,8 +84,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: 25).priority(.defaultHigh), - mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: 47).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) } } From ea5b05107db150169c27b5656207dba664357d1f Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 17:58:03 +0800 Subject: [PATCH 102/400] fix: add bottom-right corner radius and fix RTL layout issue for media indicator view --- .../PlayerContainerView+MediaTypeIndicotorView.swift | 4 ++++ Mastodon/Scene/Share/View/Container/PlayerContainerView.swift | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index 3bff6ef75..a76c27972 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -51,6 +51,10 @@ extension PlayerContainerView { maskLayer.frame = bounds maskLayer.path = path.cgPath layer.mask = maskLayer + + layer.cornerRadius = PlayerContainerView.cornerRadius + layer.maskedCorners = [.layerMaxXMaxYCorner] + layer.cornerCurve = .continuous } } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3401bfe9e..3a42560a9 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -65,7 +65,7 @@ extension PlayerContainerView { playerViewController.contentOverlayView!.addSubview(mediaTypeIndicotorView) NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), - mediaTypeIndicotorView.trailingAnchor.constraint(equalTo: playerViewController.contentOverlayView!.trailingAnchor), + mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) @@ -83,7 +83,7 @@ extension PlayerContainerView { contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.trailingAnchor.constraint(equalTo: contentWarningOverlayView.trailingAnchor), + mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), ]) From 4005581d241754a6a5e2cebf11909792fe6adf51 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:02:42 +0800 Subject: [PATCH 103/400] chore: add Xcode previews --- ...ContainerView+MediaTypeIndicotorView.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift index a76c27972..12f822986 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView+MediaTypeIndicotorView.swift @@ -100,4 +100,39 @@ extension PlayerContainerView.MediaTypeIndicotorView { } +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PlayerContainerViewMediaTypeIndicotorView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .gif) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + UIViewPreview(width: 47) { + let view = PlayerContainerView.MediaTypeIndicotorView() + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 25), + view.widthAnchor.constraint(equalToConstant: 47), + ]) + view.setMediaKind(kind: .video) + return view + } + .previewLayout(.fixed(width: 47, height: 25)) + } + } + +} + +#endif From 6eb3816bab5e660ef75a5a19635b6fe3c2a2bd5d Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:19:45 +0800 Subject: [PATCH 104/400] chore: renaming reblog --- .../Diffiable/Section/StatusSection.swift | 12 ++--- ...Provider+StatusTableViewCellDelegate.swift | 4 +- .../StatusProvider/StatusProviderFacade.swift | 39 ++++++++------ .../Scene/Share/View/Content/StatusView.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 6 +-- .../View/ToolBar/ActionToolBarContainer.swift | 38 ++++++------- .../APIService/APIService+Reblog.swift | 28 ++++++---- .../MastodonSDK/API/Mastodon+API+Reblog.swift | 54 ++++++++----------- 8 files changed, 94 insertions(+), 91 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fad4898d5..90669d391 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -294,7 +294,7 @@ extension StatusSection { let toot = object as? Toot else { return } StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) - os_log("%{public}s[%{public}ld], %{public}s: boost count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) } .store(in: &cell.disposeBag) @@ -313,14 +313,14 @@ extension StatusSection { return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) - // set boost - let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false - let boostCountTitle: String = { + // set reblog + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let reblogCountTitle: String = { let count = toot.reblogsCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() - cell.statusView.actionToolbarContainer.boostButton.setTitle(boostCountTitle, for: .normal) - cell.statusView.actionToolbarContainer.isBoostButtonHighlight = isBoosted + cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged // set like let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index fddd13049..7650471fd 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -16,8 +16,8 @@ import ActiveLabel // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { - StatusProviderFacade.responseToStatusBoostAction(provider: self, cell: cell) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 9d1e33219..0febef175 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -130,7 +130,7 @@ extension StatusProviderFacade { ) } - static func responseToStatusBoostAction(provider: StatusProvider, cell: UITableViewCell) { + static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusBoostAction( provider: provider, toot: provider.toot(for: cell, indexPath: nil) @@ -160,21 +160,21 @@ extension StatusProviderFacade { let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.BoostKind)? in + .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in guard let toot = toot?.reblog ?? toot else { return nil } - let boostKind: Mastodon.API.Reblog.BoostKind = { - let isBoosted = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false - return isBoosted ? .undoBoost : .boost + let reblogKind: Mastodon.API.Reblog.ReblogKind = { + let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) }() - return (toot.objectID, boostKind) + return (toot.objectID, reblogKind) } - .map { tootObjectID, boostKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.BoostKind), Error> in - return context.apiService.boost( + .map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in + return context.apiService.reblog( tootObjectID: tootObjectID, mastodonUserObjectID: mastodonUserObjectID, - boostKind: boostKind + reblogKind: reblogKind ) - .map { tootID in (tootID, boostKind) } + .map { tootID in (tootID, reblogKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -184,9 +184,14 @@ extension StatusProviderFacade { .handleEvents { _ in generator.prepare() responseFeedbackGenerator.prepare() - } receiveOutput: { _, boostKind in + } receiveOutput: { _, reblogKind in generator.impactOccurred() - os_log("%{public}s[%{public}ld], %{public}s: [Boost] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, boostKind == .boost ? "boost" : "unboost") + switch reblogKind { + case .reblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") + case .undoReblog: + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unboost") + } } receiveCompletion: { completion in switch completion { case .failure: @@ -196,10 +201,10 @@ extension StatusProviderFacade { break } } - .map { tootID, boostKind in - return context.apiService.boost( + .map { tootID, reblogKind in + return context.apiService.reblog( statusID: tootID, - boostKind: boostKind, + reblogKind: reblogKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) } @@ -212,9 +217,9 @@ extension StatusProviderFacade { } switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request fail: %{public}s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: - os_log("%{public}s[%{public}ld], %{public}s: [Boost] remote boost request success", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] remote reblog request success", ((#file as NSString).lastPathComponent), #line, #function) } } receiveValue: { response in // do nothing diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 09b9ce282..27aa3ecf8 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -49,7 +49,7 @@ final class StatusView: UIView { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) label.textColor = Asset.Colors.Label.secondary.color - label.text = "Bob boosted" + label.text = "Bob reblogged" return label }() @@ -491,7 +491,7 @@ struct StatusView_Previews: PreviewProvider { return statusView } .previewLayout(.fixed(width: 375, height: 200)) - .previewDisplayName("Boost") + .previewDisplayName("Reblog") UIViewPreview(width: 375) { let statusView = StatusView(frame: CGRect(x: 0, y: 0, width: 375, height: 500)) statusView.configure( diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 1723e47c3..75845d6a7 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -25,7 +25,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -227,8 +227,8 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) { - delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, boostButtonDidPressed: sender) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 991592c13..daaa607d9 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -10,7 +10,7 @@ import UIKit protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, boostButtonDidPressed sender: UIButton) + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -19,12 +19,12 @@ protocol ActionToolbarContainerDelegate: class { final class ActionToolbarContainer: UIView { let replyButton = HitTestExpandedButton() - let boostButton = HitTestExpandedButton() + let reblogButton = HitTestExpandedButton() let favoriteButton = HitTestExpandedButton() let moreButton = HitTestExpandedButton() - var isBoostButtonHighlight: Bool = false { - didSet { isBoostButtonHighlightStateDidChange(to: isBoostButtonHighlight) } + var isReblogButtonHighlight: Bool = false { + didSet { isReblogButtonHighlightStateDidChange(to: isReblogButtonHighlight) } } var isFavoriteButtonHighlight: Bool = false { @@ -61,7 +61,7 @@ extension ActionToolbarContainer { ]) replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) - boostButton.addTarget(self, action: #selector(ActionToolbarContainer.boostButtonDidPressed(_:)), for: .touchUpInside) + reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } @@ -93,7 +93,7 @@ extension ActionToolbarContainer { subview.removeFromSuperview() } - let buttons = [replyButton, boostButton, favoriteButton, moreButton] + let buttons = [replyButton, reblogButton, favoriteButton, moreButton] buttons.forEach { button in button.tintColor = Asset.Colors.Button.actionToolbar.color button.titleLabel?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular) @@ -113,7 +113,7 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .leading } replyButton.setImage(replyImage, for: .normal) - boostButton.setImage(reblogImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) favoriteButton.setImage(starImage, for: .normal) moreButton.setImage(moreImage, for: .normal) @@ -121,19 +121,19 @@ extension ActionToolbarContainer { container.distribution = .fill replyButton.translatesAutoresizingMaskIntoConstraints = false - boostButton.translatesAutoresizingMaskIntoConstraints = false + reblogButton.translatesAutoresizingMaskIntoConstraints = false favoriteButton.translatesAutoresizingMaskIntoConstraints = false moreButton.translatesAutoresizingMaskIntoConstraints = false container.addArrangedSubview(replyButton) - container.addArrangedSubview(boostButton) + container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) container.addArrangedSubview(moreButton) NSLayoutConstraint.activate([ replyButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), - replyButton.heightAnchor.constraint(equalTo: boostButton.heightAnchor).priority(.defaultHigh), + replyButton.heightAnchor.constraint(equalTo: reblogButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: favoriteButton.heightAnchor).priority(.defaultHigh), replyButton.heightAnchor.constraint(equalTo: moreButton.heightAnchor).priority(.defaultHigh), - replyButton.widthAnchor.constraint(equalTo: boostButton.widthAnchor).priority(.defaultHigh), + replyButton.widthAnchor.constraint(equalTo: reblogButton.widthAnchor).priority(.defaultHigh), replyButton.widthAnchor.constraint(equalTo: favoriteButton.widthAnchor).priority(.defaultHigh), ]) moreButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) @@ -144,7 +144,7 @@ extension ActionToolbarContainer { button.contentHorizontalAlignment = .center } replyButton.setImage(replyImage, for: .normal) - boostButton.setImage(reblogImage, for: .normal) + reblogButton.setImage(reblogImage, for: .normal) favoriteButton.setImage(starImage, for: .normal) container.axis = .horizontal @@ -152,7 +152,7 @@ extension ActionToolbarContainer { container.distribution = .fillEqually container.addArrangedSubview(replyButton) - container.addArrangedSubview(boostButton) + container.addArrangedSubview(reblogButton) container.addArrangedSubview(favoriteButton) } } @@ -162,11 +162,11 @@ extension ActionToolbarContainer { return oldStyle != style } - private func isBoostButtonHighlightStateDidChange(to isHighlight: Bool) { + private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color - boostButton.tintColor = tintColor - boostButton.setTitleColor(tintColor, for: .normal) - boostButton.setTitleColor(tintColor, for: .highlighted) + reblogButton.tintColor = tintColor + reblogButton.setTitleColor(tintColor, for: .normal) + reblogButton.setTitleColor(tintColor, for: .highlighted) } private func isFavoriteButtonHighlightStateDidChange(to isHighlight: Bool) { @@ -184,9 +184,9 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, replayButtonDidPressed: sender) } - @objc private func boostButtonDidPressed(_ sender: UIButton) { + @objc private func reblogButtonDidPressed(_ sender: UIButton) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, boostButtonDidPressed: sender) + delegate?.actionToolbarContainer(self, reblogButtonDidPressed: sender) } @objc private func favoriteButtonDidPressed(_ sender: UIButton) { diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 3f750954f..92ff85c10 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -15,10 +15,10 @@ import CommonOSLog extension APIService { // make local state change only - func boost( + func reblog( tootObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, - boostKind: Mastodon.API.Reblog.BoostKind + reblogKind: Mastodon.API.Reblog.ReblogKind ) -> AnyPublisher { var _targetTootID: Toot.ID? let managedObjectContext = backgroundManagedObjectContext @@ -29,7 +29,12 @@ extension APIService { let targetTootID = targetToot.id _targetTootID = targetTootID - targetToot.update(reblogged: boostKind == .boost, mastodonUser: mastodonUser) + switch reblogKind { + case .reblog: + targetToot.update(reblogged: true, mastodonUser: mastodonUser) + case .undoReblog: + targetToot.update(reblogged: false, mastodonUser: mastodonUser) + } } .tryMap { result in @@ -48,20 +53,20 @@ extension APIService { .eraseToAnyPublisher() } - // send boost request to remote - func boost( + // send reblog request to remote + func reblog( statusID: Mastodon.Entity.Status.ID, - boostKind: Mastodon.API.Reblog.BoostKind, + reblogKind: Mastodon.API.Reblog.ReblogKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization let requestMastodonUserID = mastodonAuthenticationBox.userID - return Mastodon.API.Reblog.boost( + return Mastodon.API.Reblog.reblog( session: session, domain: domain, statusID: statusID, - boostKind: boostKind, + reblogKind: reblogKind, authorization: authorization ) .map { response -> AnyPublisher, Error> in @@ -92,10 +97,13 @@ extension APIService { } APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) - if boostKind == .undoBoost { + switch reblogKind { + case .undoReblog: oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + default: + break } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld boosts", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "boost" : "unboost" } ?? "", entity.reblogsCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift index 862028d33..cb7a96188 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Reblog.swift @@ -10,7 +10,7 @@ import Combine extension Mastodon.API.Reblog { - static func boostedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + static func rebloggedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID + "/reblogged_by" return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } @@ -22,7 +22,7 @@ extension Mastodon.API.Reblog { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/3/9 + /// 2021/3/15 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/statuses/) /// - Parameters: @@ -31,14 +31,14 @@ extension Mastodon.API.Reblog { /// - statusID: id for status /// - authorization: User token. Could be nil if status is public /// - Returns: `AnyPublisher` contains `Status` nested in the response - public static func poll( + public static func rebloggedBy( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: boostedByEndpointURL(domain: domain, statusID: statusID), + url: rebloggedByEndpointURL(domain: domain, statusID: statusID), query: nil, authorization: authorization ) @@ -66,7 +66,7 @@ extension Mastodon.API.Reblog { /// - Since: 0.0.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/3/9 + /// 2021/3/15 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/statuses/) /// - Parameters: @@ -75,11 +75,11 @@ extension Mastodon.API.Reblog { /// - statusID: id for status /// - authorization: User token. /// - Returns: `AnyPublisher` contains `Status` nested in the response - public static func boost( + public static func reblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, - query: BoostQuery, + query: ReblogQuery, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( @@ -97,10 +97,10 @@ extension Mastodon.API.Reblog { public typealias Visibility = Mastodon.Entity.Source.Privacy - public struct BoostQuery: Codable, PostQuery { - public let visibility: Visibility + public struct ReblogQuery: Codable, PostQuery { + public let visibility: Visibility? - public init(visibility: Visibility) { + public init(visibility: Visibility?) { self.visibility = visibility } } @@ -114,7 +114,7 @@ extension Mastodon.API.Reblog { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } - /// Undo boost + /// Undo reblog /// /// Undo a reshare of a status. /// @@ -130,7 +130,7 @@ extension Mastodon.API.Reblog { /// - statusID: id for status /// - authorization: User token. /// - Returns: `AnyPublisher` contains `Status` nested in the response - public static func undoBoost( + public static func undoReblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, @@ -153,34 +153,24 @@ extension Mastodon.API.Reblog { extension Mastodon.API.Reblog { - public enum BoostKind { - case boost - case undoBoost + public enum ReblogKind { + case reblog(query: ReblogQuery) + case undoReblog } - public static func boost( + public static func reblog( session: URLSession, domain: String, statusID: Mastodon.Entity.Status.ID, - boostKind: BoostKind, + reblogKind: ReblogKind, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { - let url: URL - switch boostKind { - case .boost: url = reblogEndpointURL(domain: domain, statusID: statusID) - case .undoBoost: url = unreblogEndpointURL(domain: domain, statusID: statusID) + switch reblogKind { + case .reblog(let query): + return reblog(session: session, domain: domain, statusID: statusID, query: query, authorization: authorization) + case .undoReblog: + return undoReblog(session: session, domain: domain, statusID: statusID, authorization: authorization) } - let request = Mastodon.API.post( - url: url, - query: nil, - authorization: authorization - ) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() } } From fdf5b5fa663a7d98cdcf328731a146191a4c5931 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 18:22:44 +0800 Subject: [PATCH 105/400] chore: update i18n --- Localization/app.json | 2 +- Mastodon/Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Strings.swift | 6 +++--- .../Protocol/StatusProvider/StatusProviderFacade.swift | 10 +++++----- Mastodon/Resources/en.lproj/Localizable.strings | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 123655955..4c79a97bb 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -36,7 +36,7 @@ "open_in_safari": "Open in Safari" }, "status": { - "user_boosted": "%s boosted", + "user_reblogged": "%s reblogged", "show_post": "Show Post", "status_content_warning": "content warning", "media_content_warning": "Tap to reveal that may be sensitive", diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 90669d391..4b0532d5c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -99,7 +99,7 @@ extension StatusSection { cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userBoosted(name) + return L10n.Common.Controls.Status.userReblogged(name) }() // set name username diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7c595918e..399d1a5e9 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -76,9 +76,9 @@ internal enum L10n { internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") /// content warning internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") - /// %@ boosted - internal static func userBoosted(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Status.UserBoosted", String(describing: p1)) + /// %@ reblogged + internal static func userReblogged(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) } internal enum Poll { /// Closed diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 0febef175..cab16e68c 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -123,21 +123,21 @@ extension StatusProviderFacade { extension StatusProviderFacade { - static func responseToStatusBoostAction(provider: StatusProvider) { - _responseToStatusBoostAction( + static func responseToStatusReblogAction(provider: StatusProvider) { + _responseToStatusReblogAction( provider: provider, toot: provider.toot() ) } static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { - _responseToStatusBoostAction( + _responseToStatusReblogAction( provider: provider, toot: provider.toot(for: cell, indexPath: nil) ) } - private static func _responseToStatusBoostAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -190,7 +190,7 @@ extension StatusProviderFacade { case .reblog: os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") case .undoReblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unboost") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") } } receiveCompletion: { completion in switch completion { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 54b69e274..c79e3d0c2 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -30,7 +30,7 @@ "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; -"Common.Controls.Status.UserBoosted" = "%@ boosted"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Timeline.LoadMore" = "Load More"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; @@ -92,4 +92,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; \ No newline at end of file +back in your hands."; From 1a60428f2a52cd9ab2048979456902ce40a27d6d Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 19:25:44 +0800 Subject: [PATCH 106/400] feat: implement emojis preloading logic --- Mastodon.xcodeproj/project.pbxproj | 16 ++++++------ .../Scene/MainTab/MainTabBarController.swift | 12 +++++++++ .../APIService/APIService+CustomEmoji.swift | 2 +- ...vice+CustomEmojiViewModel+LoadState.swift} | 22 ++++++++-------- ...> EmojiService+CustomEmojiViewModel.swift} | 13 ++++------ .../Service/EmojiService/EmojiService.swift | 26 ++++++++++++++++--- 6 files changed, 60 insertions(+), 31 deletions(-) rename Mastodon/Service/EmojiService/{EmojiService+CustomEmoji+LoadState.swift => EmojiService+CustomEmojiViewModel+LoadState.swift} (78%) rename Mastodon/Service/EmojiService/{EmojiService+CustomEmoji.swift => EmojiService+CustomEmojiViewModel.swift} (76%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e75ae17ce..feeb6e3b1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -142,8 +142,8 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; - DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */; }; - DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */; }; + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; @@ -412,8 +412,8 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; - DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji.swift"; sourceTree = ""; }; - DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmoji+LoadState.swift"; sourceTree = ""; }; + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; @@ -995,8 +995,8 @@ isa = PBXGroup; children = ( DB49A61325FF2C5600B98345 /* EmojiService.swift */, - DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmoji.swift */, - DB49A62425FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift */, + DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */, + DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */, ); path = EmojiService; sourceTree = ""; @@ -1710,7 +1710,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, - DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmoji.swift in Sources */, + DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -1793,7 +1793,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, - DB49A62525FF334C00B98345 /* EmojiService+CustomEmoji+LoadState.swift in Sources */, + DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index a556854e5..1c8e8816b 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,6 +123,18 @@ extension MainTabBarController { } } .store(in: &disposeBag) + + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .sink { [weak self] activeMastodonAuthenticationBox in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let domain = activeMastodonAuthenticationBox.domain + + // trigger dequeue to preload emojis + _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/APIService/APIService+CustomEmoji.swift b/Mastodon/Service/APIService/APIService+CustomEmoji.swift index 96dbcb96b..2a80eca4c 100644 --- a/Mastodon/Service/APIService/APIService+CustomEmoji.swift +++ b/Mastodon/Service/APIService/APIService+CustomEmoji.swift @@ -1,5 +1,5 @@ // -// APIService+CustomEmoji.swift +// APIService+CustomEmojiViewModel.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift similarity index 78% rename from Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift rename to Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift index 3ac6a49a2..4fdab0bb9 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji+LoadState.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift @@ -1,5 +1,5 @@ // -// EmojiService+CustomEmoji+LoadState.swift +// EmojiService+CustomEmojiViewModel+LoadState.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. @@ -9,11 +9,11 @@ import os.log import Foundation import GameplayKit -extension EmojiService.CustomEmoji { +extension EmojiService.CustomEmojiViewModel { class LoadState: GKState { - weak var viewModel: EmojiService.CustomEmoji? + weak var viewModel: EmojiService.CustomEmojiViewModel? - init(viewModel: EmojiService.CustomEmoji) { + init(viewModel: EmojiService.CustomEmojiViewModel) { self.viewModel = viewModel } @@ -23,24 +23,24 @@ extension EmojiService.CustomEmoji { } } -extension EmojiService.CustomEmoji.LoadState { +extension EmojiService.CustomEmojiViewModel.LoadState { - class Initial: EmojiService.CustomEmoji.LoadState { + class Initial: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Loading.self } } - class Loading: EmojiService.CustomEmoji.LoadState { + class Loading: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Finish.self } override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) - guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let viewModel = viewModel, let apiService = viewModel.service?.apiService, let stateMachine = stateMachine else { return } - viewModel.context.apiService.customEmoji(domain: viewModel.domain) + apiService.customEmoji(domain: viewModel.domain) .receive(on: DispatchQueue.main) .sink { completion in switch completion { @@ -59,7 +59,7 @@ extension EmojiService.CustomEmoji.LoadState { } } - class Fail: EmojiService.CustomEmoji.LoadState { + class Fail: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Loading.self || stateClass == Finish.self } @@ -76,7 +76,7 @@ extension EmojiService.CustomEmoji.LoadState { } } - class Finish: EmojiService.CustomEmoji.LoadState { + class Finish: EmojiService.CustomEmojiViewModel.LoadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { // one time task return false diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift similarity index 76% rename from Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift rename to Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index aa253cf9a..f866f4a02 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmoji.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -1,5 +1,5 @@ // -// EmojiService+CustomEmoji.swift +// EmojiService+CustomEmojiViewModel.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-15. @@ -11,13 +11,13 @@ import GameplayKit import MastodonSDK extension EmojiService { - final class CustomEmoji { + final class CustomEmojiViewModel { var disposeBag = Set() // input let domain: String - let context: AppContext + weak var service: EmojiService? // output private(set) lazy var stateMachine: GKStateMachine = { @@ -33,12 +33,9 @@ extension EmojiService { }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) - init(domain: String, context: AppContext) { + init(domain: String, service: EmojiService) { self.domain = domain - self.context = context - - // trigger loading - stateMachine.enter(LoadState.Loading.self) + self.service = service } } diff --git a/Mastodon/Service/EmojiService/EmojiService.swift b/Mastodon/Service/EmojiService/EmojiService.swift index 468c2e6dd..3883d4bab 100644 --- a/Mastodon/Service/EmojiService/EmojiService.swift +++ b/Mastodon/Service/EmojiService/EmojiService.swift @@ -12,15 +12,35 @@ import MastodonSDK final class EmojiService { - let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") weak var apiService: APIService? - // ouput - + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.EmojiService.working-queue") + private(set) var customEmojiViewModelDict: [String: CustomEmojiViewModel] = [:] init(apiService: APIService) { self.apiService = apiService } + +} + +extension EmojiService { + + func dequeueCustomEmojiViewModel(for domain: String) -> CustomEmojiViewModel? { + var _customEmojiViewModel: CustomEmojiViewModel? + workingQueue.sync { + if let viewModel = customEmojiViewModelDict[domain] { + _customEmojiViewModel = viewModel + } else { + let viewModel = CustomEmojiViewModel(domain: domain, service: self) + _customEmojiViewModel = viewModel + + // trigger loading + viewModel.stateMachine.enter(CustomEmojiViewModel.LoadState.Loading.self) + } + } + return _customEmojiViewModel + } + } From e6b9252e6c13a787d74ca2db75435f315fe2c164 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 15 Mar 2021 19:31:31 +0800 Subject: [PATCH 107/400] chore: remove redundant layout --- .../View/Container/MosaicImageViewContainer.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 8e6884463..b3c03a46b 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -63,7 +63,6 @@ extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { extension MosaicImageViewContainer { private func _init() { - contentWarningOverlayView.delegate = self container.translatesAutoresizingMaskIntoConstraints = false container.axis = .horizontal container.distribution = .fillEqually @@ -77,13 +76,7 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint ]) - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: container.leadingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: container.bottomAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: container.trailingAnchor), - ]) + contentWarningOverlayView.delegate = self } } @@ -101,6 +94,7 @@ extension MosaicImageViewContainer { contentWarningOverlayView.removeFromSuperview() contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 + contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] container.spacing = 1 From 0b046e46730173a7b4b079b375fe76dded1bc2d9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 15 Mar 2021 20:03:40 +0800 Subject: [PATCH 108/400] feature: add navigationBar state --- Localization/app.json | 10 +- Mastodon.xcodeproj/project.pbxproj | 8 ++ Mastodon/Generated/Strings.swift | 10 ++ .../Resources/en.lproj/Localizable.strings | 4 + .../HomeTimelineNavigationBarState.swift | 115 ++++++++++++++++++ .../HomeTimelineNavigationBarView.swift | 70 +++++++++++ .../HomeTimelineViewController.swift | 8 +- ...omeTimelineViewModel+LoadLatestState.swift | 7 +- ...omeTimelineViewModel+LoadMiddleState.swift | 3 + ...omeTimelineViewModel+LoadOldestState.swift | 2 + .../HomeTimeline/HomeTimelineViewModel.swift | 7 ++ 11 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift diff --git a/Localization/app.json b/Localization/app.json index 4c79a97bb..6170c0008 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -169,10 +169,16 @@ } }, "home_timeline": { - "title": "Home" + "title": "Home", + "navigation_bar_state": { + "offline": "Offline", + "new_posts": "See new posts", + "published": "Published!", + "Publishing": "Publishing post..." + }, }, "public_timeline": { "title": "Public" } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9859676c1..8b75de62c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -72,6 +72,8 @@ 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; }; + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; @@ -318,6 +320,8 @@ 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = ""; }; + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.swift; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; @@ -612,6 +616,8 @@ 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */, + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -1603,7 +1609,9 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 399d1a5e9..166a61221 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -162,6 +162,16 @@ internal enum L10n { internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") + internal enum NavigationBarState { + /// See new posts + internal static let newPosts = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.NewPosts") + /// Offline + internal static let offline = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Offline") + /// Published! + internal static let published = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Published") + /// Publishing post... + internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") + } } internal enum PublicTimeline { /// Public diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c79e3d0c2..2a72b92c0 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -46,6 +46,10 @@ "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift new file mode 100644 index 000000000..41be16f8b --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -0,0 +1,115 @@ +// +// HomeTimelineNavigationBarState.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import Combine +import Foundation +import UIKit + +final class HomeTimelineNavigationBarState { + static let errorCountMax: Int = 3 + var disposeBag = Set() + var errorCountDownDispose: AnyCancellable? + var networkErrorCountSubject = PassthroughSubject() + + var titleViewBeforePublishing: UIView? // used for restore titleView after published + + var newTopContent = CurrentValueSubject(false) + var newBottomContent = CurrentValueSubject(false) + var hasContentBeforeFetching: Bool = true + + weak var viewController: HomeTimelineViewController? + + init() { + reCountdown() + subscribeNewContent() + } +} + +extension HomeTimelineNavigationBarState { + func showOfflineInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView + } + + func showNewPostsInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView + } + + func showPublishingNewPostInNavigationBar() { + titleViewBeforePublishing = viewController?.navigationItem.titleView + } + + func showPublishedInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { + if let titleView = self.titleViewBeforePublishing, let navigationItem = self.viewController?.navigationItem { + navigationItem.titleView = titleView + } + } + } + + func showMastodonLogoInNavigationBar() { + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView + } +} + +extension HomeTimelineNavigationBarState { + func subscribeNewContent() { + newTopContent + .receive(on: DispatchQueue.main) + .sink { [weak self] newContent in + guard let self = self else { return } + if self.hasContentBeforeFetching && newContent { + self.showNewPostsInNavigationBar() + } + } + .store(in: &disposeBag) + newBottomContent + .receive(on: DispatchQueue.main) + .sink { [weak self] newContent in + guard let self = self else { return } + if newContent { + self.showNewPostsInNavigationBar() + } + } + .store(in: &disposeBag) + + } + func reCountdown() { + errorCountDownDispose = networkErrorCountSubject + .scan(0) { value, _ in value + 1 } + .sink(receiveValue: { [weak self] errorCount in + guard let self = self else { return } + if errorCount >= HomeTimelineNavigationBarState.errorCountMax { + self.showOfflineInNavigationBar() + } + }) + } + + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + let contentOffsetY = scrollView.contentOffset.y + print(contentOffsetY) + let isTop = contentOffsetY < -scrollView.contentInset.top + if isTop { + newTopContent.value = false + showMastodonLogoInNavigationBar() + } + let isBottom = contentOffsetY > max(-scrollView.contentInset.top, scrollView.contentSize.height - scrollView.frame.height + scrollView.contentInset.bottom) + if isBottom { + newBottomContent.value = false + showMastodonLogoInNavigationBar() + } + } + + func receiveCompletion(completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorCountSubject.send(false) + case .finished: + reCountdown() + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift new file mode 100644 index 000000000..b8906ab02 --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -0,0 +1,70 @@ +// +// HomeTimelineNavigationBarView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import UIKit + +final class HomeTimelineNavigationBarView { + + static let mastodonLogoTitleView: UIImageView = { + let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) + imageView.tintColor = Asset.Colors.Label.primary.color + return imageView + }() + + static let offlineView: UIView = { + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightDangerRed.color) + let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline) + HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) + return view + }() + + static let newPostsView: UIView = { + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.highlight.color) + let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts) + HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) + return view + }() + + static var publishedView: UIView = { + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightSuccessGreen.color) + let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.published) + HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) + return view + }() + + + static func addLabelToView(label: UILabel,view:UIView) { + view.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), + label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1), + view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1) + ]) + label.sizeToFit() + view.layoutIfNeeded() + view.layer.cornerRadius = view.frame.height/2 + view.clipsToBounds = true + } + + static func backgroundViewWithColor(color:UIColor) -> UIView { + let view = UIView() + view.backgroundColor = color + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + static func contentLabel(text: String) -> UILabel { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) + label.text = text + return label + } +} + diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 9db551f62..fe273241a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -64,11 +64,7 @@ extension HomeTimelineViewController { title = L10n.Scene.HomeTimeline.title view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - navigationItem.titleView = { - let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) - imageView.tintColor = Asset.Colors.Label.primary.color - return imageView - }() + navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView navigationItem.leftBarButtonItem = settingBarButtonItem #if DEBUG // long press to trigger debug menu @@ -101,6 +97,7 @@ extension HomeTimelineViewController { ]) viewModel.tableView = tableView + viewModel.viewController = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self viewModel.setupDiffableDataSource( @@ -208,6 +205,7 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) + self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index c085471c6..0df4334a0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -73,6 +73,7 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Fail.self) return } + viewModel.homeTimelineNavigationBarState.hasContentBeforeFetching = !latestTootIDs.isEmpty let end = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) @@ -80,6 +81,7 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) .receive(on: DispatchQueue.main) .sink { completion in + viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): // TODO: handle error @@ -97,9 +99,12 @@ extension HomeTimelineViewModel.LoadLatestState { let toots = response.value let newToots = toots.filter { !latestTootIDs.contains($0.id) } os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) - + if newToots.isEmpty { viewModel.isFetchingLatestTimeline.value = false + viewModel.homeTimelineNavigationBarState.newTopContent.value = false + } else { + viewModel.homeTimelineNavigationBarState.newTopContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 5a212f357..7b7f3a70a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -68,6 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in + viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): // TODO: handle error @@ -82,8 +83,10 @@ extension HomeTimelineViewModel.LoadMiddleState { os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) if newToots.isEmpty { stateMachine.enter(Fail.self) + viewModel.homeTimelineNavigationBarState.newTopContent.value = false } else { stateMachine.enter(Success.self) + viewModel.homeTimelineNavigationBarState.newTopContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index c6eb988b3..5bca33bdb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -70,8 +70,10 @@ extension HomeTimelineViewModel.LoadOldestState { // enter no more state when no new toots if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { stateMachine.enter(NoMore.self) + viewModel.homeTimelineNavigationBarState.newBottomContent.value = false } else { stateMachine.enter(Idle.self) + viewModel.homeTimelineNavigationBarState.newBottomContent.value = true } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 44457839a..263e76a9d 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -29,9 +29,16 @@ final class HomeTimelineViewModel: NSObject { let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() + let homeTimelineNavigationBarState = HomeTimelineNavigationBarState() + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + weak var viewController: HomeTimelineViewController? { + willSet(value) { + self.homeTimelineNavigationBarState.viewController = value + } + } // output // top loader From 21362b56c3e9e34cfa779552076c0fef714505bf Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 15 Mar 2021 20:23:27 +0800 Subject: [PATCH 109/400] chore: add gesture to scroll manually --- Mastodon.xcodeproj/project.pbxproj | 4 ++ Mastodon/Extension/UIScrollView.swift | 32 +++++++++ .../HomeTimelineNavigationBarState.swift | 66 +++++++++++++------ 3 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 Mastodon/Extension/UIScrollView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8b75de62c..57979578f 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -74,6 +74,7 @@ 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; }; 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; }; + 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; @@ -322,6 +323,7 @@ 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = ""; }; 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.swift; sourceTree = ""; }; + 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; @@ -1145,6 +1147,7 @@ 2D206B9125F60EA700143C56 /* UIControl.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, + 2D84350425FF858100EECE90 /* UIScrollView.swift */, ); path = Extension; sourceTree = ""; @@ -1646,6 +1649,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, diff --git a/Mastodon/Extension/UIScrollView.swift b/Mastodon/Extension/UIScrollView.swift new file mode 100644 index 000000000..8999d255c --- /dev/null +++ b/Mastodon/Extension/UIScrollView.swift @@ -0,0 +1,32 @@ +// +// UIScrollView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import UIKit + +extension UIScrollView { + public enum ScrollDirection { + case top + case bottom + case left + case right + } + + public func scroll(to direction: ScrollDirection, animated: Bool) { + let offset: CGPoint + switch direction { + case .top: + offset = CGPoint(x: contentOffset.x, y: -adjustedContentInset.top) + case .bottom: + offset = CGPoint(x: contentOffset.x, y: max(-adjustedContentInset.top, contentSize.height - frame.height + adjustedContentInset.bottom)) + case .left: + offset = CGPoint(x: -adjustedContentInset.left, y: contentOffset.y) + case .right: + offset = CGPoint(x: max(-adjustedContentInset.left, contentSize.width - frame.width + adjustedContentInset.right), y: contentOffset.y) + } + setContentOffset(offset, animated: animated) + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift index 41be16f8b..7dc4223a1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -17,8 +17,8 @@ final class HomeTimelineNavigationBarState { var titleViewBeforePublishing: UIView? // used for restore titleView after published - var newTopContent = CurrentValueSubject(false) - var newBottomContent = CurrentValueSubject(false) + var newTopContent = CurrentValueSubject(false) + var newBottomContent = CurrentValueSubject(false) var hasContentBeforeFetching: Bool = true weak var viewController: HomeTimelineViewController? @@ -26,6 +26,7 @@ final class HomeTimelineNavigationBarState { init() { reCountdown() subscribeNewContent() + addGesture() } } @@ -56,15 +57,54 @@ extension HomeTimelineNavigationBarState { } } +extension HomeTimelineNavigationBarState { + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + let contentOffsetY = scrollView.contentOffset.y + print(contentOffsetY) + let isTop = contentOffsetY < -scrollView.contentInset.top + if isTop { + newTopContent.value = false + showMastodonLogoInNavigationBar() + } + let isBottom = contentOffsetY > max(-scrollView.adjustedContentInset.top, scrollView.contentSize.height - scrollView.frame.height + scrollView.adjustedContentInset.bottom) + if isBottom { + newBottomContent.value = false + showMastodonLogoInNavigationBar() + } + } + + func addGesture() { + let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer + tapGesture.addTarget(self, action: #selector(newPostsNewDidPressed)) + HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture) + } + + @objc func newPostsNewDidPressed() { + if newTopContent.value == true { + scrollToDirection(direction: .top) + } + if newBottomContent.value == true { + scrollToDirection(direction: .bottom) + } + } + + func scrollToDirection(direction: UIScrollView.ScrollDirection) { + viewController?.tableView.scroll(to: direction, animated: true) + } +} + extension HomeTimelineNavigationBarState { func subscribeNewContent() { newTopContent .receive(on: DispatchQueue.main) .sink { [weak self] newContent in guard let self = self else { return } - if self.hasContentBeforeFetching && newContent { + if self.hasContentBeforeFetching, newContent { self.showNewPostsInNavigationBar() } + if newContent { + self.newBottomContent.value = false + } } .store(in: &disposeBag) newBottomContent @@ -74,10 +114,13 @@ extension HomeTimelineNavigationBarState { if newContent { self.showNewPostsInNavigationBar() } + if (newContent) { + self.newTopContent.value = false + } } .store(in: &disposeBag) - } + func reCountdown() { errorCountDownDispose = networkErrorCountSubject .scan(0) { value, _ in value + 1 } @@ -89,21 +132,6 @@ extension HomeTimelineNavigationBarState { }) } - func handleScrollViewDidScroll(_ scrollView: UIScrollView) { - let contentOffsetY = scrollView.contentOffset.y - print(contentOffsetY) - let isTop = contentOffsetY < -scrollView.contentInset.top - if isTop { - newTopContent.value = false - showMastodonLogoInNavigationBar() - } - let isBottom = contentOffsetY > max(-scrollView.contentInset.top, scrollView.contentSize.height - scrollView.frame.height + scrollView.contentInset.bottom) - if isBottom { - newBottomContent.value = false - showMastodonLogoInNavigationBar() - } - } - func receiveCompletion(completion: Subscribers.Completion) { switch completion { case .failure: From 50a30cd18e5af23a6912059e3907ce6c1249cd16 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 10:12:03 +0800 Subject: [PATCH 110/400] chore: export colors from zeplin --- Mastodon/Generated/Assets.swift | 4 +++ .../backgroundLight.colorset/Contents.json | 20 ++++++++++++++ .../buttonDefault.colorset/Contents.json | 20 ++++++++++++++ .../buttonDisabled.colorset/Contents.json | 20 ++++++++++++++ .../buttonInactive.colorset/Contents.json | 20 ++++++++++++++ .../lightAlertYellow.colorset/Contents.json | 14 +++++----- .../lightDangerRed.colorset/Contents.json | 16 ++++++------ .../lightDarkGray.colorset/Contents.json | 16 ++++++------ .../lightSecondaryText.colorset/Contents.json | 26 +++++++++---------- .../lightSuccessGreen.colorset/Contents.json | 20 +++++++------- .../Colors/lightWhite.colorset/Contents.json | 18 ++++++------- .../Resources/en.lproj/Localizable.strings | 2 +- 12 files changed, 140 insertions(+), 56 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index f0222a9e0..cc94eaf6a 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -67,6 +67,10 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } + internal static let backgroundLight = ColorAsset(name: "Colors/backgroundLight") + internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault") + internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled") + internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive") internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json new file mode 100644 index 000000000..0e4687fb4 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.910", + "green" : "0.882", + "red" : "0.851" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json new file mode 100644 index 000000000..2e1ce5f3a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json new file mode 100644 index 000000000..78cde95fb --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json new file mode 100644 index 000000000..69dc63851 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json index 29b7bba3d..0e29336a8 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json @@ -1,20 +1,20 @@ { "colors" : [ { + "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { - "alpha" : "1.000", + "red" : "0.792", "blue" : "0.016", "green" : "0.561", - "red" : "0.792" + "alpha" : "1.000" } - }, - "idiom" : "universal" + } } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json index dabccc33e..8ea3105e6 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json @@ -1,20 +1,20 @@ { + "info" : { + "version" : 1, + "author" : "xcode" + }, "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", + "red" : "0.875", "blue" : "0.353", - "green" : "0.251", - "red" : "0.875" + "green" : "0.251" } }, "idiom" : "universal" } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} + ] +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json index 8d54c84c2..e6461f1d3 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json @@ -1,6 +1,11 @@ { + "info" : { + "version" : 1, + "author" : "xcode" + }, "colors" : [ { + "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { @@ -9,12 +14,7 @@ "green" : "0.137", "red" : "0.122" } - }, - "idiom" : "universal" + } } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} + ] +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json index ba375b791..ac36bf1f4 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json @@ -1,20 +1,20 @@ { + "info" : { + "version" : 1, + "author" : "xcode" + }, "colors" : [ { + "idiom" : "universal", "color" : { - "color-space" : "srgb", "components" : { + "blue" : "0.263", + "green" : "0.235", "alpha" : "0.600", - "blue" : "67", - "green" : "60", - "red" : "60" - } - }, - "idiom" : "universal" + "red" : "0.235" + }, + "color-space" : "srgb" + } } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} + ] +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json index 8716dcb74..8ef654ce0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json @@ -1,20 +1,20 @@ { + "info" : { + "version" : 1, + "author" : "xcode" + }, "colors" : [ { + "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.604", "green" : "0.741", - "red" : "0.475" + "red" : "0.475", + "blue" : "0.604" } - }, - "idiom" : "universal" + } } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} + ] +} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json index a5291a593..5147016be 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json @@ -1,20 +1,20 @@ { "colors" : [ { + "idiom" : "universal", "color" : { - "color-space" : "srgb", "components" : { + "red" : "0.996", "alpha" : "1.000", "blue" : "0.996", - "green" : "1.000", - "red" : "0.996" - } - }, - "idiom" : "universal" + "green" : "1.000" + }, + "color-space" : "srgb" + } } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } -} +} \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 2a72b92c0..5ed3c68a6 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -96,4 +96,4 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file From 7705e54e679f5e57ccd9d548381a9313334fba8a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:23:19 +0800 Subject: [PATCH 111/400] chore: renaming --- Mastodon/Diffiable/Item/ComposeStatusItem.swift | 8 ++++---- Mastodon/Diffiable/Section/ComposeStatusSection.swift | 4 ++-- Mastodon/Scene/Compose/ComposeViewController.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift | 8 ++++---- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- .../HomeTimelineViewController+DebugAction.swift | 8 ++++---- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 812a27a6f..79655b946 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -10,14 +10,14 @@ import Combine import CoreData enum ComposeStatusItem { - case replyTo(tootObjectID: NSManagedObjectID) - case toot(replyToTootObjectID: NSManagedObjectID?, attribute: ComposeTootAttribute) + case replyTo(statusObjectID: NSManagedObjectID) + case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) } extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { - final class ComposeTootAttribute: Equatable, Hashable { + final class ComposeStatusAttribute: Equatable, Hashable { private let id = UUID() let avatarURL = CurrentValueSubject(nil) @@ -25,7 +25,7 @@ extension ComposeStatusItem { let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) - static func == (lhs: ComposeTootAttribute, rhs: ComposeTootAttribute) -> Bool { + static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { return lhs.avatarURL.value == rhs.avatarURL.value && lhs.displayName.value == rhs.displayName.value && lhs.username.value == rhs.username.value && diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index ce08fc009..835007dcc 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -37,7 +37,7 @@ extension ComposeStatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell // TODO: return cell - case .toot(let replyToTootObjectID, let attribute): + case .input(let replyToTootObjectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, @@ -67,7 +67,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { static func configure( cell: ComposeTootContentTableViewCell, - attribute: ComposeStatusItem.ComposeTootAttribute + attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar attribute.avatarURL diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 7ad5f7174..84531b114 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -176,7 +176,7 @@ extension ComposeViewController { let items = diffableDataSource.snapshot().itemIdentifiers for item in items { switch item { - case .toot: + case .input: guard let indexPath = diffableDataSource.indexPath(for: item), let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { continue diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index b175aaca1..a3a0515e6 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -26,11 +26,11 @@ extension ComposeViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status]) switch composeKind { - case .reply(let tootObjectID): - snapshot.appendItems([.replyTo(tootObjectID: tootObjectID)], toSection: .repliedTo) - snapshot.appendItems([.toot(replyToTootObjectID: tootObjectID, attribute: composeTootAttribute)], toSection: .status) + case .reply(let statusObjectID): + snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) + snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) case .post: - snapshot.appendItems([.toot(replyToTootObjectID: nil, attribute: composeTootAttribute)], toSection: .status) + snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 097de6ae5..a357b8740 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -17,7 +17,7 @@ final class ComposeViewModel { // input let context: AppContext let composeKind: ComposeStatusSection.ComposeKind - let composeTootAttribute = ComposeStatusItem.ComposeTootAttribute() + let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let composeContent = CurrentValueSubject("") let activeAuthentication: CurrentValueSubject diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 08696db9d..548409fbc 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -76,9 +76,9 @@ extension HomeTimelineViewController { identifier: nil, options: [], children: [50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Toots", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.dropRecentTootsAction(action, count: count) + self.dropRecentStatusAction(action, count: count) }) } ) @@ -118,11 +118,11 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found poll toot") + print("Not found status contains poll") } } - @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { + @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() From f69086e6c3b6cfdc77d09c63e7f6f6e84ee4d1f1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:23:34 +0800 Subject: [PATCH 112/400] chore: move emoji preload to compose scene --- Mastodon/Scene/Compose/ComposeViewModel.swift | 30 +++++++++++++++---- .../Scene/MainTab/MainTabBarController.swift | 12 -------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index a357b8740..8084ab53b 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -29,6 +29,10 @@ final class ComposeViewModel { let shouldDismiss = CurrentValueSubject(true) let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + // custom emojis + let customEmojiViewModel = CurrentValueSubject(nil) + + init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -47,24 +51,26 @@ final class ComposeViewModel { .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) + // bind avatar and names activeAuthentication .sink { [weak self] mastodonAuthentication in guard let self = self else { return } let mastodonUser = mastodonAuthentication?.user let username = mastodonUser?.username ?? " " - self.composeTootAttribute.avatarURL.value = mastodonUser?.avatarImageURL() - self.composeTootAttribute.displayName.value = { + self.composeStatusAttribute.avatarURL.value = mastodonUser?.avatarImageURL() + self.composeStatusAttribute.displayName.value = { guard let displayName = mastodonUser?.displayName, !displayName.isEmpty else { return username } return displayName }() - self.composeTootAttribute.username.value = username + self.composeStatusAttribute.username.value = username } .store(in: &disposeBag) - composeTootAttribute.composeContent + // bind compose bar button item UI state + composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) .map { content in let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -73,7 +79,8 @@ final class ComposeViewModel { .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) .store(in: &disposeBag) - composeTootAttribute.composeContent + // bind modal dismiss state + composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) .map { content in let content = content ?? "" @@ -81,6 +88,19 @@ final class ComposeViewModel { } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) + + // bind custom emojis + context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) + .sink { [weak self] activeMastodonAuthenticationBox in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let domain = activeMastodonAuthenticationBox.domain + + // trigger dequeue to preload emojis + _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 1c8e8816b..a556854e5 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,18 +123,6 @@ extension MainTabBarController { } } .store(in: &disposeBag) - - context.authenticationService.activeMastodonAuthenticationBox - .receive(on: DispatchQueue.main) - .sink { [weak self] activeMastodonAuthenticationBox in - guard let self = self else { return } - guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } - let domain = activeMastodonAuthenticationBox.domain - - // trigger dequeue to preload emojis - _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) - } - .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 From b60fe36b25356774cb0faa7df11bf8dc1fff8696 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 11:45:30 +0800 Subject: [PATCH 113/400] chore: add publishing state in navigationBar --- Mastodon.xcodeproj/project.pbxproj | 4 ++ .../HomeTimelineNavigationBarState.swift | 50 ++++++++++++++--- .../HomeTimelineNavigationBarView.swift | 13 +++++ ...omeTimelineViewModel+LoadOldestState.swift | 1 + .../Content/NavigationBarProgressView.swift | 56 +++++++++++++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 57979578f..c7e86949d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; + 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; }; @@ -300,6 +301,7 @@ 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; + 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlContainableScrollViews.swift; sourceTree = ""; }; @@ -593,6 +595,7 @@ children = ( 2D152A8B25C295CC009AA50C /* StatusView.swift */, 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, + 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, ); path = Content; sourceTree = ""; @@ -1605,6 +1608,7 @@ 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, + 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift index 7dc4223a1..6650d323b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -9,20 +9,25 @@ import Combine import Foundation import UIKit + final class HomeTimelineNavigationBarState { static let errorCountMax: Int = 3 var disposeBag = Set() var errorCountDownDispose: AnyCancellable? + var timerDispose: AnyCancellable? var networkErrorCountSubject = PassthroughSubject() - var titleViewBeforePublishing: UIView? // used for restore titleView after published - var newTopContent = CurrentValueSubject(false) var newBottomContent = CurrentValueSubject(false) var hasContentBeforeFetching: Bool = true weak var viewController: HomeTimelineViewController? + let timestampUpdatePublisher = Timer.publish(every: NavigationBarProgressView.progressAnimationDuration, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + init() { reCountdown() subscribeNewContent() @@ -40,15 +45,42 @@ extension HomeTimelineNavigationBarState { } func showPublishingNewPostInNavigationBar() { - titleViewBeforePublishing = viewController?.navigationItem.titleView + let progressView = HomeTimelineNavigationBarView.progressView + if let navigationBar = viewController?.navigationBar(), progressView.superview == nil { + navigationBar.addSubview(progressView) + NSLayoutConstraint.activate([ + progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), + progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), + progressView.heightAnchor.constraint(equalToConstant: 3) + ]) + } + progressView.layoutIfNeeded() + progressView.progress = 0 + viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishingLabel + + var times: Int = 0 + timerDispose = timestampUpdatePublisher + .map { _ in + times += 1 + return Double(times) + } + .scan(0) { value,count in + value + 1 / pow(Double(2), count) + } + .receive(on: DispatchQueue.main) + .sink { value in + print(value) + progressView.progress = CGFloat(value) + } } func showPublishedInNavigationBar() { + timerDispose = nil + HomeTimelineNavigationBarView.progressView.removeFromSuperview() viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { - if let titleView = self.titleViewBeforePublishing, let navigationItem = self.viewController?.navigationItem { - navigationItem.titleView = titleView - } + self.showMastodonLogoInNavigationBar() } } @@ -60,7 +92,10 @@ extension HomeTimelineNavigationBarState { extension HomeTimelineNavigationBarState { func handleScrollViewDidScroll(_ scrollView: UIScrollView) { let contentOffsetY = scrollView.contentOffset.y - print(contentOffsetY) + let isShowingNewPostsNew = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.newPostsView + if !isShowingNewPostsNew { + return + } let isTop = contentOffsetY < -scrollView.contentInset.top if isTop { newTopContent.value = false @@ -138,6 +173,7 @@ extension HomeTimelineNavigationBarState { networkErrorCountSubject.send(false) case .finished: reCountdown() + showPublishingNewPostInNavigationBar() } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index b8906ab02..1669f0124 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -36,6 +36,19 @@ final class HomeTimelineNavigationBarView { return view }() + static var progressView: NavigationBarProgressView = { + let view = NavigationBarProgressView() + return view + }() + + static var publishingLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .black + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing + return label + }() static func addLabelToView(label: UILabel,view:UIView) { view.addSubview(label) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 5bca33bdb..84bde2e4a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -58,6 +58,7 @@ extension HomeTimelineViewModel.LoadOldestState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in + viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift new file mode 100644 index 000000000..d011ca897 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift @@ -0,0 +1,56 @@ +// +// NavigationBarProgressView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/16. +// + +import UIKit + +class NavigationBarProgressView: UIView { + + static let progressAnimationDuration: TimeInterval = 0.3 + + let sliderView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.buttonDefault.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var sliderTrailingAnchor: NSLayoutConstraint! + + var progress: CGFloat = 0 { + willSet(value) { + sliderTrailingAnchor.constant = (1 - progress) * bounds.width + UIView.animate(withDuration: NavigationBarProgressView.progressAnimationDuration) { + self.setNeedsLayout() + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } +} + +extension NavigationBarProgressView { + func _init() { + self.translatesAutoresizingMaskIntoConstraints = false + self.backgroundColor = .clear + addSubview(sliderView) + sliderTrailingAnchor = trailingAnchor.constraint(equalTo: sliderView.trailingAnchor) + NSLayoutConstraint.activate([ + sliderView.topAnchor.constraint(equalTo: topAnchor), + sliderView.leadingAnchor.constraint(equalTo: leadingAnchor), + sliderView.bottomAnchor.constraint(equalTo: bottomAnchor), + sliderTrailingAnchor + ]) + } +} From 1a3cff8a3abd6384df28c6d57cfd6da47598a704 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:48:14 +0800 Subject: [PATCH 114/400] chore: add debug entries --- ...meTimelineViewController+DebugAction.swift | 115 ++++++++++++++---- 1 file changed, 88 insertions(+), 27 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 9f2b4e720..74ea5c3e5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -45,34 +45,30 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToTopGapAction(action) }), - UIAction(title: "First Reblog Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstReblogToot(action) + self.moveToFirstRepliedStatus(action) }), - UIAction(title: "First Poll Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstPollToot(action) + self.moveToFirstReblogStatus(action) }), - UIAction(title: "First Audio Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } - self.moveToFirstAudioToot(action) + self.moveToFirstPollStatus(action) + }), + UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioStatus(action) + }), + UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstVideoStatus(action) + }), + UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstGIFStatus(action) }), -// UIAction(title: "First Reply Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstReplyToot(action) -// }), -// UIAction(title: "First Reply Reblog", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstReplyReblog(action) -// }), -// UIAction(title: "First Video Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstVideoToot(action) -// }), -// UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in -// guard let self = self else { return } -// self.moveToFirstGIFToot(action) -// }), ] ) } @@ -109,7 +105,7 @@ extension HomeTimelineViewController { } } - @objc private func moveToFirstReblogToot(_ sender: UIAction) { + @objc private func moveToFirstReblogStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -125,11 +121,11 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found reblog toot") + print("Not found reblog status") } } - @objc private func moveToFirstPollToot(_ sender: UIAction) { + @objc private func moveToFirstPollStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -146,11 +142,34 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found poll toot") + print("Not found poll status") } } - @objc private func moveToFirstAudioToot(_ sender: UIAction) { + @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + guard homeTimelineIndex.toot.inReplyToID != nil else { + return false + } + return true + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found replied status") + } + } + + @objc private func moveToFirstAudioStatus(_ sender: UIAction) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() let item = snapshotTransitioning.itemIdentifiers.first(where: { item in @@ -171,6 +190,48 @@ extension HomeTimelineViewController { } } + @objc private func moveToFirstVideoStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found video status") + } + } + + @objc private func moveToFirstGIFStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + return toot.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found GIF status") + } + } + @objc private func dropRecentTootsAction(_ sender: UIAction, count: Int) { guard let diffableDataSource = viewModel.diffableDataSource else { return } let snapshotTransitioning = diffableDataSource.snapshot() From 21f41247475a44b69f212c432b36c30d82e8a89d Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 11:48:33 +0800 Subject: [PATCH 115/400] fix: header icon missing issue --- Mastodon/Diffiable/Section/StatusSection.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 744e873cf..f6d0b0273 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -311,7 +311,7 @@ extension StatusSection { ) { if toot.reblog != nil { cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) cell.statusView.headerInfoLabel.text = { let author = toot.author let name = author.displayName.isEmpty ? author.username : author.displayName @@ -319,7 +319,7 @@ extension StatusSection { }() } else if let replyTo = toot.replyTo { cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerInfoLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { let author = replyTo.author let name = author.displayName.isEmpty ? author.username : author.displayName From fdcd1ffcd057fe498ae30165d15f3e77c22c2b5d Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 14:19:12 +0800 Subject: [PATCH 116/400] feat: implement inline emoji for text editor --- .../Scene/Compose/ComposeViewController.swift | 79 ++++++++++++++++--- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 84531b114..22c0a24ae 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -9,6 +9,7 @@ import os.log import UIKit import Combine import TwitterTextEditor +import Kingfisher final class ComposeViewController: UIViewController, NeedsDependency { @@ -18,6 +19,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ComposeViewModel! + private var suffixedAttachmentViews: [UIView] = [] + let composeTootBarButtonItem: UIBarButtonItem = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) @@ -156,6 +159,20 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeTootBarButtonItem) .store(in: &disposeBag) + + // bind custom emojis + viewModel.customEmojiViewModel + .compactMap { $0?.emojis } + .switchToLatest() + .sink(receiveValue: { [weak self] emojis in + guard let self = self else { return } + for emoji in emojis { + UITextChecker.learnWord(emoji.shortcode) + UITextChecker.learnWord(":" + emoji.shortcode + ":") + } + self.textEditorView()?.setNeedsUpdateTextAttributes() + }) + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -164,15 +181,16 @@ extension ComposeViewController { // Fix AutoLayout conflict issue DispatchQueue.main.async { [weak self] in guard let self = self else { return } - self.markTextViewEditorBecomeFirstResponser() + self.markTextEditorViewBecomeFirstResponser() } } } extension ComposeViewController { - private func markTextViewEditorBecomeFirstResponser() { - guard let diffableDataSource = viewModel.diffableDataSource else { return } + + private func textEditorView() -> TextEditorView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } let items = diffableDataSource.snapshot().itemIdentifiers for item in items { switch item { @@ -181,12 +199,17 @@ extension ComposeViewController { let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { continue } - cell.textEditorView.isEditing = true - return + return cell.textEditorView default: continue } } + + return nil + } + + private func markTextEditorViewBecomeFirstResponser() { + textEditorView()?.isEditing = true } private func showDismissConfirmAlertController() { @@ -233,8 +256,9 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { let stringRange = NSRange(location: 0, length: string.length) let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") - // not accept :$ to force user input space to make emoji take effect - let emojiMatches = string.matches(pattern: "(?:(^:|\\s:)([a-zA-Z0-9_]+):\\s)") + // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect + // precondition :\B with following space + let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") // only accept http/https scheme let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") @@ -243,6 +267,11 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { completion(nil) return } + let customEmojiViewModel = self.viewModel.customEmojiViewModel.value + for view in self.suffixedAttachmentViews { + view.removeFromSuperview() + } + self.suffixedAttachmentViews.removeAll() // set normal apperance let attributedString = NSMutableAttributedString(attributedString: attributedString) @@ -289,20 +318,44 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } attributedString.addAttributes(attributes, range: match.range) } - for match in emojiMatches { - if let name = string.substring(with: match, at: 2) { + + let emojis = customEmojiViewModel?.emojis.value ?? [] + if !emojis.isEmpty { + for match in emojiMatches { + guard let name = string.substring(with: match, at: 2) else { continue } + guard let emoji = emojis.first(where: { $0.shortcode == name }) else { continue } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) // set emoji token invisiable (without upper bounce space) var attributes = [NSAttributedString.Key: Any]() attributes[.font] = UIFont.systemFont(ofSize: 0.01) - let rangeWithoutUpperBounceSpace = NSRange(location: match.range.location, length: match.range.length - 1) - attributedString.addAttributes(attributes, range: rangeWithoutUpperBounceSpace) + attributedString.addAttributes(attributes, range: match.range) // append emoji attachment + let imageViewSize = CGSize(width: 20, height: 20) + let imageView = UIImageView(frame: CGRect(origin: .zero, size: imageViewSize)) + textEditorView.textContentView.addSubview(imageView) + self.suffixedAttachmentViews.append(imageView) + let processor = DownsamplingImageProcessor(size: imageViewSize) + imageView.kf.setImage( + with: URL(string: emoji.url), + placeholder: UIImage.placeholder(size: imageViewSize, color: .systemFill), + options: [ + .processor(processor), + .scaleFactor(textEditorView.traitCollection.displayScale), + ], completionHandler: nil + ) + let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in + // `textEditorView` retains `textStorage`, which retains this block as a part of attributes. + guard let textEditorView = textEditorView else { + return + } + let insets = textEditorView.textContentInsets + view.frame = frame.offsetBy(dx: insets.left, dy: insets.top) + } let attachment = TextAttributes.SuffixedAttachment( - size: CGSize(width: 20, height: 20), - attachment: .image(UIImage(systemName: "circle")!) + size: imageViewSize, + attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer) ) let index = match.range.upperBound - 1 attributedString.addAttribute( diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 8084ab53b..743f385e5 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -98,7 +98,7 @@ final class ComposeViewModel { let domain = activeMastodonAuthenticationBox.domain // trigger dequeue to preload emojis - _ = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) + self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) } From 1abe55074524d2d9ae695ebe2009a624d99e2f9e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 14:41:32 +0800 Subject: [PATCH 117/400] chore: remove navigationBar newPostsView when loadmore --- .../HomeTimelineNavigationBarState.swift | 12 +++++++----- .../HomeTimeline/HomeTimelineNavigationBarView.swift | 8 +++----- .../HomeTimelineViewModel+LoadOldestState.swift | 2 -- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift index 6650d323b..3ae74a264 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -9,7 +9,6 @@ import Combine import Foundation import UIKit - final class HomeTimelineNavigationBarState { static let errorCountMax: Int = 3 var disposeBag = Set() @@ -46,7 +45,7 @@ extension HomeTimelineNavigationBarState { func showPublishingNewPostInNavigationBar() { let progressView = HomeTimelineNavigationBarView.progressView - if let navigationBar = viewController?.navigationBar(), progressView.superview == nil { + if let navigationBar = viewController?.navigationBar(), progressView.superview == nil { navigationBar.addSubview(progressView) NSLayoutConstraint.activate([ progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), @@ -65,7 +64,7 @@ extension HomeTimelineNavigationBarState { times += 1 return Double(times) } - .scan(0) { value,count in + .scan(0) { value, count in value + 1 / pow(Double(2), count) } .receive(on: DispatchQueue.main) @@ -149,7 +148,7 @@ extension HomeTimelineNavigationBarState { if newContent { self.showNewPostsInNavigationBar() } - if (newContent) { + if newContent { self.newTopContent.value = false } } @@ -173,7 +172,10 @@ extension HomeTimelineNavigationBarState { networkErrorCountSubject.send(false) case .finished: reCountdown() - showPublishingNewPostInNavigationBar() + let isShowingOfflineView = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.offlineView + if isShowingOfflineView { + showMastodonLogoInNavigationBar() + } } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index 1669f0124..d371ffe59 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -8,7 +8,6 @@ import UIKit final class HomeTimelineNavigationBarView { - static let mastodonLogoTitleView: UIImageView = { let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) imageView.tintColor = Asset.Colors.Label.primary.color @@ -50,7 +49,7 @@ final class HomeTimelineNavigationBarView { return label }() - static func addLabelToView(label: UILabel,view:UIView) { + static func addLabelToView(label: UILabel, view: UIView) { view.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), @@ -60,11 +59,11 @@ final class HomeTimelineNavigationBarView { ]) label.sizeToFit() view.layoutIfNeeded() - view.layer.cornerRadius = view.frame.height/2 + view.layer.cornerRadius = view.frame.height / 2 view.clipsToBounds = true } - static func backgroundViewWithColor(color:UIColor) -> UIView { + static func backgroundViewWithColor(color: UIColor) -> UIView { let view = UIView() view.backgroundColor = color view.translatesAutoresizingMaskIntoConstraints = false @@ -80,4 +79,3 @@ final class HomeTimelineNavigationBarView { return label } } - diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 84bde2e4a..b18a66c01 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -71,10 +71,8 @@ extension HomeTimelineViewModel.LoadOldestState { // enter no more state when no new toots if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { stateMachine.enter(NoMore.self) - viewModel.homeTimelineNavigationBarState.newBottomContent.value = false } else { stateMachine.enter(Idle.self) - viewModel.homeTimelineNavigationBarState.newBottomContent.value = true } } .store(in: &viewModel.disposeBag) From 27307ed4dd117f501295df28c64675967d6387f3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 15:05:01 +0800 Subject: [PATCH 118/400] chore: remove newBottomContent logic --- .../HomeTimelineNavigationBarState.swift | 37 +++---------------- .../HomeTimelineNavigationBarView.swift | 9 ++--- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift index 3ae74a264..11692eaac 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -17,7 +17,6 @@ final class HomeTimelineNavigationBarState { var networkErrorCountSubject = PassthroughSubject() var newTopContent = CurrentValueSubject(false) - var newBottomContent = CurrentValueSubject(false) var hasContentBeforeFetching: Bool = true weak var viewController: HomeTimelineViewController? @@ -36,10 +35,12 @@ final class HomeTimelineNavigationBarState { extension HomeTimelineNavigationBarState { func showOfflineInNavigationBar() { + HomeTimelineNavigationBarView.progressView.removeFromSuperview() viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView } func showNewPostsInNavigationBar() { + HomeTimelineNavigationBarView.progressView.removeFromSuperview() viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView } @@ -84,6 +85,7 @@ extension HomeTimelineNavigationBarState { } func showMastodonLogoInNavigationBar() { + HomeTimelineNavigationBarView.progressView.removeFromSuperview() viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView } } @@ -100,30 +102,18 @@ extension HomeTimelineNavigationBarState { newTopContent.value = false showMastodonLogoInNavigationBar() } - let isBottom = contentOffsetY > max(-scrollView.adjustedContentInset.top, scrollView.contentSize.height - scrollView.frame.height + scrollView.adjustedContentInset.bottom) - if isBottom { - newBottomContent.value = false - showMastodonLogoInNavigationBar() - } } func addGesture() { let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(newPostsNewDidPressed)) + tapGesture.addTarget(self, action: #selector(HomeTimelineNavigationBarState.newPostsNewDidPressed(_:))) HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture) } - @objc func newPostsNewDidPressed() { + @objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) { if newTopContent.value == true { - scrollToDirection(direction: .top) + viewController?.tableView.scroll(to: .top, animated: true) } - if newBottomContent.value == true { - scrollToDirection(direction: .bottom) - } - } - - func scrollToDirection(direction: UIScrollView.ScrollDirection) { - viewController?.tableView.scroll(to: direction, animated: true) } } @@ -136,21 +126,6 @@ extension HomeTimelineNavigationBarState { if self.hasContentBeforeFetching, newContent { self.showNewPostsInNavigationBar() } - if newContent { - self.newBottomContent.value = false - } - } - .store(in: &disposeBag) - newBottomContent - .receive(on: DispatchQueue.main) - .sink { [weak self] newContent in - guard let self = self else { return } - if newContent { - self.showNewPostsInNavigationBar() - } - if newContent { - self.newTopContent.value = false - } } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index d371ffe59..c19d45e47 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -55,18 +55,17 @@ final class HomeTimelineNavigationBarView { label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1), - view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1) + view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1), + view.heightAnchor.constraint(equalToConstant: 24), ]) - label.sizeToFit() - view.layoutIfNeeded() - view.layer.cornerRadius = view.frame.height / 2 - view.clipsToBounds = true } static func backgroundViewWithColor(color: UIColor) -> UIView { let view = UIView() view.backgroundColor = color view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 12 + view.clipsToBounds = true return view } From f7b4b5993ad6be4147de7c59e0e35215aa0d56fb Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 15:37:21 +0800 Subject: [PATCH 119/400] fix: tableView can't scrolling to the top --- .../Scene/HomeTimeline/HomeTimelineNavigationBarState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift index 11692eaac..2d1da2165 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift @@ -112,7 +112,7 @@ extension HomeTimelineNavigationBarState { @objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) { if newTopContent.value == true { - viewController?.tableView.scroll(to: .top, animated: true) + viewController?.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) } } } From 12e2c5f0d5e7bd6962375ffa423eac3bb8b91d5f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 15:54:03 +0800 Subject: [PATCH 120/400] chore: remove newPostsView when load gap toots --- .../HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 7b7f3a70a..bb1211d2f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -83,10 +83,8 @@ extension HomeTimelineViewModel.LoadMiddleState { os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) if newToots.isEmpty { stateMachine.enter(Fail.self) - viewModel.homeTimelineNavigationBarState.newTopContent.value = false } else { stateMachine.enter(Success.self) - viewModel.homeTimelineNavigationBarState.newTopContent.value = true } } .store(in: &viewModel.disposeBag) From 96c148882079633339298f1018ad6ba60cd75e7a Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 16:16:44 +0800 Subject: [PATCH 121/400] fix: hashtag regex exclude list issue --- Mastodon/Scene/Compose/ComposeViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 22c0a24ae..df04b8d23 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -255,7 +255,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") From 07f3cc7a77382f3abf4a69c245ca99b722902864 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 16 Mar 2021 16:17:11 +0800 Subject: [PATCH 122/400] fix: author info UI layout issue --- .../ComposeTootContentTableViewCell.swift | 2 ++ .../Scene/Share/View/Content/StatusView.swift | 21 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift index 95e49c63a..9f39f1989 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift @@ -53,6 +53,8 @@ extension ComposeTootContentTableViewCell { ]) statusView.statusContainerStackView.isHidden = true statusView.actionToolbarContainer.isHidden = true + statusView.nameTrialingDotLabel.isHidden = true + statusView.dateLabel.isHidden = true textEditorView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(textEditorView) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 5e501b92f..3987aa5fc 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -71,6 +71,14 @@ final class StatusView: UIView { return label }() + let nameTrialingDotLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .systemFont(ofSize: 17) + label.text = "·" + return label + }() + let usernameLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .regular) @@ -268,18 +276,11 @@ extension StatusView { nameLabel.heightAnchor.constraint(equalToConstant: 22).priority(.defaultHigh), ]) titleContainerStackView.alignment = .firstBaseline - let dotLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = .systemFont(ofSize: 17) - label.text = "·" - return label - }() - titleContainerStackView.addArrangedSubview(dotLabel) + titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) titleContainerStackView.addArrangedSubview(dateLabel) nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) - dotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) - dotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) + nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) + nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) From 8fc39edd8539987f2d4539570b4bf0cc60b8ed7f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 16 Mar 2021 19:28:52 +0800 Subject: [PATCH 123/400] feature: Update timeline gap appearance --- Localization/app.json | 5 +- .../Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Assets.swift | 3 - Mastodon/Generated/Strings.swift | 8 +- .../Assets.xcassets/Arrows/Contents.json | 9 - .../Contents.json | 12 -- .../arrow.triangle.2.circlepath.pdf | 193 ------------------ .../Resources/en.lproj/Localizable.strings | 3 +- .../HomeTimelineViewController.swift | 8 +- .../PublicTimelineViewController.swift | 8 +- .../TimelineBottomLoaderTableViewCell.swift | 5 +- .../TimelineLoaderTableViewCell.swift | 53 +++-- .../TimelineMiddleLoaderTableViewCell.swift | 4 - 13 files changed, 59 insertions(+), 254 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Arrows/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf diff --git a/Localization/app.json b/Localization/app.json index b85f67323..45c771235 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -60,7 +60,10 @@ } }, "timeline": { - "load_more": "Load More" + "loader": { + "load_missing_posts": "Load missing posts", + "loading_missing_posts": "Loading missing posts..." + } } }, "countable": { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index e0620b10a..bc95e2a23 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -77,7 +77,7 @@ extension StatusSection { return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell - cell.activityIndicatorView.startAnimating() + cell.startAnimating() return cell } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index cbfdfd101..1aabf5f3f 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -22,9 +22,6 @@ internal typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name internal enum Asset { internal static let accentColor = ColorAsset(name: "AccentColor") - internal enum Arrows { - internal static let arrowTriangle2Circlepath = ImageAsset(name: "Arrows/arrow.triangle.2.circlepath") - } internal enum Asset { internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 632595035..7079b2970 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -120,8 +120,12 @@ internal enum L10n { } } internal enum Timeline { - /// Load More - internal static let loadMore = L10n.tr("Localizable", "Common.Controls.Timeline.LoadMore") + internal enum Loader { + /// Loading missing posts... + internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") + /// Load missing posts + internal static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts") + } } } internal enum Countable { diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json b/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json deleted file mode 100644 index 6e965652d..000000000 --- a/Mastodon/Resources/Assets.xcassets/Arrows/Contents.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "provides-namespace" : true - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json deleted file mode 100644 index c59347e9e..000000000 --- a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "arrow.triangle.2.circlepath.pdf", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf b/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf deleted file mode 100644 index b864ab380..000000000 --- a/Mastodon/Resources/Assets.xcassets/Arrows/arrow.triangle.2.circlepath.imageset/arrow.triangle.2.circlepath.pdf +++ /dev/null @@ -1,193 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 4.000000 10.752930 cm -0.000000 0.000000 0.000000 scn -15.009519 2.109471 m -15.085540 1.562444 15.590621 1.180617 16.137648 1.256639 c -16.684677 1.332660 17.066502 1.837741 16.990480 2.384768 c -15.009519 2.109471 l -h --0.423099 4.631682 m --0.635487 4.121869 -0.394376 3.536408 0.115438 3.324021 c -0.625251 3.111633 1.210711 3.352744 1.423099 3.862558 c --0.423099 4.631682 l -h -1.000000 8.247120 m -1.000000 8.799404 0.552285 9.247120 0.000000 9.247120 c --0.552285 9.247120 -1.000000 8.799404 -1.000000 8.247120 c -1.000000 8.247120 l -h -0.000000 4.247120 m --1.000000 4.247120 l --1.000000 3.694835 -0.552285 3.247120 0.000000 3.247120 c -0.000000 4.247120 l -h -4.000000 3.247120 m -4.552285 3.247120 5.000000 3.694835 5.000000 4.247120 c -5.000000 4.799405 4.552285 5.247120 4.000000 5.247120 c -4.000000 3.247120 l -h -16.990480 2.384768 m -16.715729 4.361807 15.798570 6.193669 14.380284 7.598174 c -12.972991 6.177073 l -14.079566 5.081251 14.795152 3.651996 15.009519 2.109471 c -16.990480 2.384768 l -h -14.380284 7.598174 m -12.961998 9.002679 11.121269 9.901910 9.141643 10.157345 c -8.885699 8.173789 l -10.430243 7.974494 11.866417 7.272897 12.972991 6.177073 c -14.380284 7.598174 l -h -9.141643 10.157345 m -7.162015 10.412781 5.153316 10.010252 3.424967 9.011765 c -4.425436 7.279984 l -5.773929 8.059025 7.341156 8.373085 8.885699 8.173789 c -9.141643 10.157345 l -h -3.424967 9.011765 m -1.696617 8.013276 0.344502 6.474223 -0.423099 4.631682 c -1.423099 3.862558 l -2.021996 5.300145 3.076944 6.500945 4.425436 7.279984 c -3.424967 9.011765 l -h --1.000000 8.247120 m --1.000000 4.247120 l -1.000000 4.247120 l -1.000000 8.247120 l --1.000000 8.247120 l -h -0.000000 3.247120 m -4.000000 3.247120 l -4.000000 5.247120 l -0.000000 5.247120 l -0.000000 3.247120 l -h -f -n -Q -q -1.000000 0.000000 -0.000000 1.000000 4.000000 1.767822 cm -0.000000 0.000000 0.000000 scn -0.990481 9.369826 m -0.914460 9.916854 0.409379 10.298680 -0.137649 10.222659 c --0.684676 10.146638 -1.066502 9.641557 -0.990481 9.094529 c -0.990481 9.369826 l -h -16.423100 6.847616 m -16.635487 7.357429 16.394375 7.942889 15.884562 8.155277 c -15.374748 8.367664 14.789289 8.126554 14.576900 7.616740 c -16.423100 6.847616 l -h -15.000000 3.232178 m -15.000000 2.679893 15.447715 2.232178 16.000000 2.232178 c -16.552284 2.232178 17.000000 2.679893 17.000000 3.232178 c -15.000000 3.232178 l -h -16.000000 7.232178 m -17.000000 7.232178 l -17.000000 7.784462 16.552284 8.232178 16.000000 8.232178 c -16.000000 7.232178 l -h -12.000000 8.232178 m -11.447715 8.232178 11.000000 7.784462 11.000000 7.232178 c -11.000000 6.679893 11.447715 6.232178 12.000000 6.232178 c -12.000000 8.232178 l -h --0.990481 9.094529 m --0.715729 7.117491 0.201429 5.285628 1.619715 3.881123 c -3.027008 5.302223 l -1.920433 6.398046 1.204848 7.827302 0.990481 9.369826 c --0.990481 9.094529 l -h -1.619715 3.881123 m -3.038001 2.476617 4.878731 1.577388 6.858358 1.321952 c -7.114300 3.305508 l -5.569757 3.504804 4.133582 4.206400 3.027008 5.302223 c -1.619715 3.881123 l -h -6.858358 1.321952 m -8.837985 1.066517 10.846684 1.469046 12.575033 2.467534 c -11.574564 4.199314 l -10.226071 3.420273 8.658844 3.106212 7.114300 3.305508 c -6.858358 1.321952 l -h -12.575033 2.467534 m -14.303383 3.466022 15.655499 5.005074 16.423100 6.847616 c -14.576900 7.616740 l -13.978004 6.179152 12.923057 4.978354 11.574564 4.199314 c -12.575033 2.467534 l -h -17.000000 3.232178 m -17.000000 7.232178 l -15.000000 7.232178 l -15.000000 3.232178 l -17.000000 3.232178 l -h -16.000000 8.232178 m -12.000000 8.232178 l -12.000000 6.232178 l -16.000000 6.232178 l -16.000000 8.232178 l -h -f -n -Q - -endstream -endobj - -3 0 obj - 3597 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 24.000000 24.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000003687 00000 n -0000003710 00000 n -0000003883 00000 n -0000003957 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -4016 -%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 3515da672..92e0161a9 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -34,7 +34,8 @@ "Common.Controls.Status.ShowPost" = "Show Post"; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; -"Common.Controls.Timeline.LoadMore" = "Load More"; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; "Scene.Compose.ComposeAction" = "Publish"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 2dd7a3adf..a18410536 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -268,15 +268,13 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // make success state same as loading due to snapshot updating delay let isLoading = state is HomeTimelineViewModel.LoadMiddleState.Loading || state is HomeTimelineViewModel.LoadMiddleState.Success - cell.loadMoreButton.isHidden = isLoading if isLoading { - cell.activityIndicatorView.startAnimating() + cell.startAnimating() } else { - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } else { - cell.loadMoreButton.isHidden = false - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } .store(in: &cell.disposeBag) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 1fc0978e2..7b3732dcd 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -165,15 +165,13 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat // make success state same as loading due to snapshot updating delay let isLoading = state is PublicTimelineViewModel.LoadMiddleState.Loading || state is PublicTimelineViewModel.LoadMiddleState.Success - cell.loadMoreButton.isHidden = isLoading if isLoading { - cell.activityIndicatorView.startAnimating() + cell.startAnimating() } else { - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } else { - cell.loadMoreButton.isHidden = false - cell.activityIndicatorView.stopAnimating() + cell.stopAnimating() } } .store(in: &cell.disposeBag) diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift index 7fe4c0a77..0f5fe67c8 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift @@ -11,10 +11,7 @@ import Combine final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() - backgroundColor = .clear - - activityIndicatorView.isHidden = false - activityIndicatorView.startAnimating() + startAnimating() } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 6aa195241..19e1dd4e6 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -10,22 +10,30 @@ import Combine class TimelineLoaderTableViewCell: UITableViewCell { - static let cellHeight: CGFloat = 44 + TimelineLoaderTableViewCell.extraTopPadding + TimelineLoaderTableViewCell.bottomPadding - static let extraTopPadding: CGFloat = 0 // the status cell already has 10pt bottom padding - static let bottomPadding: CGFloat = StatusTableViewCell.bottomPaddingHeight + TimelineLoaderTableViewCell.extraTopPadding // make balance + static let buttonHeight: CGFloat = 62 + static let cellHeight: CGFloat = TimelineLoaderTableViewCell.buttonHeight + 17 + static let extraTopPadding: CGFloat = 10 + var disposeBag = Set() + var stateBindDispose: AnyCancellable? + let loadMoreButton: UIButton = { let button = UIButton(type: .system) - button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.setTitle(L10n.Common.Controls.Timeline.loadMore, for: .normal) + button.backgroundColor = Asset.Colors.lightWhite.color return button }() - let activityIndicatorView: UIActivityIndicatorView = { + private let loadMoreLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + private let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.tintColor = .white + activityIndicatorView.tintColor = Asset.Colors.lightSecondaryText.color activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }() @@ -44,6 +52,17 @@ class TimelineLoaderTableViewCell: UITableViewCell { super.init(coder: coder) _init() } + func startAnimating() { + activityIndicatorView.startAnimating() + self.loadMoreLabel.textColor = Asset.Colors.lightSecondaryText.color + self.loadMoreLabel.text = L10n.Common.Controls.Timeline.Loader.loadingMissingPosts + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color + self.loadMoreLabel.text = L10n.Common.Controls.Timeline.Loader.loadMissingPosts + } func _init() { selectionStyle = .none @@ -52,21 +71,27 @@ class TimelineLoaderTableViewCell: UITableViewCell { loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) NSLayoutConstraint.activate([ - loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.extraTopPadding), - loadMoreButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - contentView.readableContentGuide.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.bottomPadding), - loadMoreButton.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 14), + loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.defaultHigh), + ]) + + loadMoreLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(loadMoreLabel) + NSLayoutConstraint.activate([ + loadMoreLabel.centerXAnchor.constraint(equalTo: loadMoreButton.centerXAnchor), + loadMoreLabel.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor), ]) activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false addSubview(activityIndicatorView) NSLayoutConstraint.activate([ - activityIndicatorView.centerXAnchor.constraint(equalTo: loadMoreButton.centerXAnchor), activityIndicatorView.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor), + activityIndicatorView.trailingAnchor.constraint(equalTo: loadMoreLabel.leadingAnchor), ]) - loadMoreButton.isHidden = true activityIndicatorView.isHidden = true } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 16ab241f0..952def9bc 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -21,10 +21,6 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() - backgroundColor = .clear - - loadMoreButton.isHidden = false - loadMoreButton.setImage(Asset.Arrows.arrowTriangle2Circlepath.image.withRenderingMode(.alwaysTemplate), for: .normal) loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) } From 4873d8649b7187b193e4c956848caea31adf8efa Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 17 Mar 2021 11:33:25 +0800 Subject: [PATCH 124/400] chore: renaming status --- Mastodon.xcodeproj/project.pbxproj | 51 ++++++++----------- .../APIService/APIService+Favorite.swift | 2 +- .../APIService/APIService+HomeTimeline.swift | 2 +- .../APIService+PublicTimeline.swift | 2 +- .../APIService/APIService+Status.swift | 2 +- ...swift => APIService+CoreData+Status.swift} | 6 +-- .../APIService+Persist+PersistMemo.swift | 2 +- ....swift => APIService+Persist+Status.swift} | 4 +- .../Entity/Mastodon+Entity+Status.swift | 2 +- 9 files changed, 33 insertions(+), 40 deletions(-) rename Mastodon/Service/APIService/CoreData/{APIService+CoreData+Toot.swift => APIService+CoreData+Status.swift} (98%) rename Mastodon/Service/APIService/Persist/{APIService+Persist+Toot.swift => APIService+Persist+Status.swift} (99%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 60754172b..d2d581fd3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -57,13 +57,13 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; - 2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */; }; + 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */; }; + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; @@ -147,11 +147,11 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; - DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -220,12 +220,12 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; - DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; - DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -332,12 +332,12 @@ 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; - 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Toot.swift"; sourceTree = ""; }; + 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Toot.swift"; sourceTree = ""; }; + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; @@ -433,11 +433,11 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -505,12 +505,12 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; - DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; - DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -743,7 +743,7 @@ 2D61335625C1887F00CAE157 /* Persist */ = { isa = PBXGroup; children = ( - 2D61335725C188A000CAE157 /* APIService+Persist+Toot.swift */, + 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */, DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */, DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */, ); @@ -1028,7 +1028,7 @@ DB45FB0925CA87BC005A8AC7 /* CoreData */ = { isa = PBXGroup; children = ( - 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Toot.swift */, + 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, ); @@ -1229,21 +1229,15 @@ DB084B5125CBC56300F898ED /* CoreDataStack */, DB6C8C0525F0921200AAA452 /* MastodonSDK */, DB44384E25E8C1FA008912A2 /* CALayer.swift */, - DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, - DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, - 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, - 2D939AB425EDD8A90076FA61 /* String.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, DB4481B825EE289600BEFB67 /* UITableView.swift */, - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, 2D206B7F25F5F45E00143C56 /* UIImage.swift */, @@ -1251,7 +1245,6 @@ 2D206B9125F60EA700143C56 /* UIControl.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, @@ -1326,6 +1319,14 @@ path = ViewModel; sourceTree = ""; }; + DB9E0D6925EDFFE500CFDD76 /* Helper */ = { + isa = PBXGroup; + children = ( + 2D42FF6A25C817D2004A627A /* TootContent.swift */, + ); + path = Helper; + sourceTree = ""; + }; DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( @@ -1335,14 +1336,6 @@ path = Control; sourceTree = ""; }; - DB9E0D6925EDFFE500CFDD76 /* Helper */ = { - isa = PBXGroup; - children = ( - 2D42FF6A25C817D2004A627A /* TootContent.swift */, - ); - path = Helper; - sourceTree = ""; - }; DBABE3F125ECAC4E00879EE5 /* View */ = { isa = PBXGroup; children = ( @@ -1757,10 +1750,10 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, - 2D61335825C188A000CAE157 /* APIService+Persist+Toot.swift in Sources */, + 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, - 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Toot.swift in Sources */, + 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 4150e796e..2eacff573 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -141,7 +141,7 @@ extension APIService { .map { response -> AnyPublisher, Error> in let log = OSLog.api - return APIService.Persist.persistToots( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: mastodonAuthenticationBox.domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 125c05208..2fbb45e0b 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -40,7 +40,7 @@ extension APIService { authorization: authorization ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistToots( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index 288d1fd2e..cd02526d6 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -39,7 +39,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistToots( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: query, diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index 88ac064af..d02b04796 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -28,7 +28,7 @@ extension APIService { authorization: authorization ) .flatMap { response -> AnyPublisher, Error> in - return APIService.Persist.persistToots( + return APIService.Persist.persistStatus( managedObjectContext: self.backgroundManagedObjectContext, domain: domain, query: nil, diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift similarity index 98% rename from Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift rename to Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index a4db46a8c..28bfd9727 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Toot.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -1,5 +1,5 @@ // -// APIService+CoreData+Toot.swift +// APIService+CoreData+Status.swift // Mastodon // // Created by sxiaojian on 2021/2/3. @@ -13,7 +13,7 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeToot( + static func createOrMergeStatus( into managedObjectContext: NSManagedObjectContext, for requestMastodonUser: MastodonUser?, domain: String, @@ -31,7 +31,7 @@ extension APIService.CoreData { // build tree let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeToot( + let (toot, _, _) = createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift index 08696ec55..b74bb7771 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -163,7 +163,7 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { let children = [reblogMemo].compactMap { $0 } - let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeToot( + let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift similarity index 99% rename from Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift rename to Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index fb2c2e6c0..2944e66a5 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Toot.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -1,5 +1,5 @@ // -// APIService+Persist+Toot.swift +// APIService+Persist+Status.swift // Mastodon // // Created by sxiaojian on 2021/1/27. @@ -22,7 +22,7 @@ extension APIService.Persist { case lookUp } - static func persistToots( + static func persistStatus( managedObjectContext: NSManagedObjectContext, domain: String, query: Mastodon.API.Timeline.TimelineQuery?, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 31a8806a7..490429fce 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -1,5 +1,5 @@ // -// Mastodon+Entity+Toot.swift +// Mastodon+Entity+Status.swift // // // Created by MainasuK Cirno on 2021/1/27. From 62ad86b3130010c5f13bd84b55eb41547de32d8b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 17 Mar 2021 12:17:48 +0800 Subject: [PATCH 125/400] chore: add sawToothView --- Mastodon.xcodeproj/project.pbxproj | 47 ++++++++++--------- .../Share/View/Decoration/SawToothView.swift | 45 ++++++++++++++++++ .../TimelineMiddleLoaderTableViewCell.swift | 14 ++++++ 3 files changed, 85 insertions(+), 21 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Decoration/SawToothView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4cbdb48bc..26e99547e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; + 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; @@ -147,11 +148,11 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; - DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */; }; + DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; @@ -215,12 +216,12 @@ DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */; }; DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C2D25E504AC0051B173 /* Attachment.swift */; }; DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6C3725E508BE0051B173 /* Attachment.swift */; }; + DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; - DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; - DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; @@ -351,6 +352,7 @@ 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; + 2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = ""; }; 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLoaderTableViewCell.swift; sourceTree = ""; }; @@ -428,11 +430,11 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; - DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CustomEmoji.swift"; sourceTree = ""; }; + DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlayerContainerView+MediaTypeIndicotorView.swift"; sourceTree = ""; }; DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; @@ -495,12 +497,12 @@ DB9D6C2225E502C60051B173 /* MosaicImageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MosaicImageViewModel.swift; sourceTree = ""; }; DB9D6C2D25E504AC0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; DB9D6C3725E508BE0051B173 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; + DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; - DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; - DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; @@ -798,6 +800,7 @@ 2D7631A525C1532D00929FB9 /* View */ = { isa = PBXGroup; children = ( + 2DA504672601ADBA008F4E6C /* Decoration */, 2D42FF8325C82245004A627A /* Button */, 2D42FF7C25C82207004A627A /* ToolBar */, DB9D6C1325E4F97A0051B173 /* Container */, @@ -833,6 +836,14 @@ path = Item; sourceTree = ""; }; + 2DA504672601ADBA008F4E6C /* Decoration */ = { + isa = PBXGroup; + children = ( + 2DA504682601ADE7008F4E6C /* SawToothView.swift */, + ); + path = Decoration; + sourceTree = ""; + }; 2DF75BB725D1473400694EC8 /* Stack */ = { isa = PBXGroup; children = ( @@ -1214,21 +1225,15 @@ DB084B5125CBC56300F898ED /* CoreDataStack */, DB6C8C0525F0921200AAA452 /* MastodonSDK */, DB44384E25E8C1FA008912A2 /* CALayer.swift */, - DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, - DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, - 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, 2DF123A625C3B0210020F248 /* ActiveLabel.swift */, 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, - 2D939AB425EDD8A90076FA61 /* String.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, DB45FAD625CA6C76005A8AC7 /* UIBarButtonItem.swift */, DB4481B825EE289600BEFB67 /* UITableView.swift */, - 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, 2D206B7F25F5F45E00143C56 /* UIImage.swift */, @@ -1236,7 +1241,6 @@ 2D206B9125F60EA700143C56 /* UIControl.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, - 2D46976325C2A71500CF4AA9 /* UIIamge.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, @@ -1311,6 +1315,14 @@ path = ViewModel; sourceTree = ""; }; + DB9E0D6925EDFFE500CFDD76 /* Helper */ = { + isa = PBXGroup; + children = ( + 2D42FF6A25C817D2004A627A /* TootContent.swift */, + ); + path = Helper; + sourceTree = ""; + }; DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( @@ -1320,14 +1332,6 @@ path = Control; sourceTree = ""; }; - DB9E0D6925EDFFE500CFDD76 /* Helper */ = { - isa = PBXGroup; - children = ( - 2D42FF6A25C817D2004A627A /* TootContent.swift */, - ); - path = Helper; - sourceTree = ""; - }; DBABE3F125ECAC4E00879EE5 /* View */ = { isa = PBXGroup; children = ( @@ -1729,6 +1733,7 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, + 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, diff --git a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift new file mode 100644 index 000000000..9ae49190b --- /dev/null +++ b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift @@ -0,0 +1,45 @@ +// +// SawToothView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/17. +// + +import Foundation +import UIKit + +final class SawToothView: UIView { + static let widthUint = 8 + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func _init() { + backgroundColor = Asset.Colors.lightBackground.color + } + + override func draw(_ rect: CGRect) { + let bezierPath = UIBezierPath() + let bottomY = rect.height + let topY = 0 + let count = Int(ceil(rect.width / CGFloat(SawToothView.widthUint))) + bezierPath.move(to: CGPoint(x: 0, y: bottomY)) + for n in 0 ..< count { + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 0.5) * Double(SawToothView.widthUint)), y: CGFloat(topY))) + bezierPath.addLine(to: CGPoint(x: CGFloat((Double(n) + 1) * Double(SawToothView.widthUint)), y: CGFloat(bottomY))) + } + bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) + bezierPath.close() + UIColor.white.setFill() + bezierPath.fill() + bezierPath.lineWidth = 0 + bezierPath.stroke() + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 952def9bc..1804f2c6e 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -18,11 +18,25 @@ protocol TimelineMiddleLoaderTableViewCellDelegate: class { final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? + let sawToothView: SawToothView = { + let sawToothView = SawToothView() + sawToothView.translatesAutoresizingMaskIntoConstraints = false + return sawToothView + }() + override func _init() { super._init() loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + + contentView.addSubview(sawToothView) + NSLayoutConstraint.activate([ + sawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + sawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + sawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + sawToothView.heightAnchor.constraint(equalToConstant: 3), + ]) } } From ad2da554b54261d310c531c390f388cbc32a80c0 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 17 Mar 2021 16:16:55 +0800 Subject: [PATCH 126/400] feat: adapt Dark Mode. Fix bottom loader appearance --- .../Share/View/Decoration/SawToothView.swift | 5 +- .../TimelineBottomLoaderTableViewCell.swift | 3 + .../TimelineLoaderTableViewCell.swift | 62 +++++++++++++------ .../TimelineMiddleLoaderTableViewCell.swift | 4 ++ 4 files changed, 52 insertions(+), 22 deletions(-) diff --git a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift index 9ae49190b..ef1c89cc0 100644 --- a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift +++ b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift @@ -22,7 +22,7 @@ final class SawToothView: UIView { } func _init() { - backgroundColor = Asset.Colors.lightBackground.color + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color } override func draw(_ rect: CGRect) { @@ -37,9 +37,10 @@ final class SawToothView: UIView { } bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) bezierPath.close() - UIColor.white.setFill() + Asset.Colors.Background.secondaryGroupedSystemBackground.color.setFill() bezierPath.fill() bezierPath.lineWidth = 0 bezierPath.stroke() } + } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift index 0f5fe67c8..8d3589fb1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift @@ -11,6 +11,9 @@ import Combine final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() + + activityIndicatorView.isHidden = false + startAnimating() } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 19e1dd4e6..fe54380ed 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -13,27 +13,31 @@ class TimelineLoaderTableViewCell: UITableViewCell { static let buttonHeight: CGFloat = 62 static let cellHeight: CGFloat = TimelineLoaderTableViewCell.buttonHeight + 17 static let extraTopPadding: CGFloat = 10 - + static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() var stateBindDispose: AnyCancellable? let loadMoreButton: UIButton = { - let button = UIButton(type: .system) - button.backgroundColor = Asset.Colors.lightWhite.color + let button = HighlightDimmableButton() + button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitle(L10n.Common.Controls.Timeline.Loader.loadMissingPosts, for: .normal) + button.setTitle("", for: .disabled) return button }() - private let loadMoreLabel: UILabel = { + let loadMoreLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) + label.font = TimelineLoaderTableViewCell.labelFont return label }() - private let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.tintColor = Asset.Colors.lightSecondaryText.color + activityIndicatorView.tintColor = Asset.Colors.Label.secondary.color activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }() @@ -52,21 +56,24 @@ class TimelineLoaderTableViewCell: UITableViewCell { super.init(coder: coder) _init() } + func startAnimating() { activityIndicatorView.startAnimating() - self.loadMoreLabel.textColor = Asset.Colors.lightSecondaryText.color + self.loadMoreButton.isEnabled = false + self.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color self.loadMoreLabel.text = L10n.Common.Controls.Timeline.Loader.loadingMissingPosts } func stopAnimating() { activityIndicatorView.stopAnimating() + self.loadMoreButton.isEnabled = true self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color - self.loadMoreLabel.text = L10n.Common.Controls.Timeline.Loader.loadMissingPosts + self.loadMoreLabel.text = "" } func _init() { selectionStyle = .none - backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) @@ -75,23 +82,38 @@ class TimelineLoaderTableViewCell: UITableViewCell { loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 14), - loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.defaultHigh), + loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.required - 1), ]) - loadMoreLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(loadMoreLabel) + // use stack view to alignlment content center + let stackView = UIStackView() + stackView.spacing = 4 + stackView.axis = .horizontal + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isUserInteractionEnabled = false + contentView.addSubview(stackView) NSLayoutConstraint.activate([ - loadMoreLabel.centerXAnchor.constraint(equalTo: loadMoreButton.centerXAnchor), - loadMoreLabel.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor), + stackView.topAnchor.constraint(equalTo: loadMoreButton.topAnchor), + stackView.leadingAnchor.constraint(equalTo: loadMoreButton.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor), ]) - - activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false - addSubview(activityIndicatorView) + let leftPaddingView = UIView() + leftPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(leftPaddingView) + stackView.addArrangedSubview(activityIndicatorView) + stackView.addArrangedSubview(loadMoreLabel) + let rightPaddingView = UIView() + rightPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(rightPaddingView) NSLayoutConstraint.activate([ - activityIndicatorView.centerYAnchor.constraint(equalTo: loadMoreButton.centerYAnchor), - activityIndicatorView.trailingAnchor.constraint(equalTo: loadMoreLabel.leadingAnchor), + leftPaddingView.widthAnchor.constraint(equalTo: rightPaddingView.widthAnchor, multiplier: 1.0), ]) + // default set hidden and let subclass override it + loadMoreButton.isHidden = true + loadMoreLabel.isHidden = true activityIndicatorView.isHidden = true } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 1804f2c6e..597420481 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -27,6 +27,10 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { override func _init() { super._init() + loadMoreButton.isHidden = false + loadMoreLabel.isHidden = false + activityIndicatorView.isHidden = false + loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) From 221ec27c471a835cc27449032c9ab1e413dd305d Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 17 Mar 2021 18:09:12 +0800 Subject: [PATCH 127/400] fix: AutoLayout warning for media type indicator view --- .../Scene/Share/View/Container/PlayerContainerView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 3a42560a9..2c2229466 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -66,8 +66,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorView.bottomAnchor.constraint(equalTo: playerViewController.contentOverlayView!.bottomAnchor), mediaTypeIndicotorView.rightAnchor.constraint(equalTo: playerViewController.contentOverlayView!.rightAnchor), - mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), - mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + mediaTypeIndicotorView.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) addSubview(contentWarningOverlayView) @@ -84,8 +84,8 @@ extension PlayerContainerView { NSLayoutConstraint.activate([ mediaTypeIndicotorViewInContentWarningOverlay.bottomAnchor.constraint(equalTo: contentWarningOverlayView.bottomAnchor), mediaTypeIndicotorViewInContentWarningOverlay.rightAnchor.constraint(equalTo: contentWarningOverlayView.rightAnchor), - mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.defaultHigh), - mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.defaultHigh), + mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), + mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) } } From 5ecce85bfd579e5ff3637895139880ddacb2c9a2 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 17 Mar 2021 18:09:38 +0800 Subject: [PATCH 128/400] feat: add image media attachment item for diffable data source --- Mastodon.xcodeproj/project.pbxproj | 22 +++++++-- .../Diffiable/Item/ComposeStatusItem.swift | 1 + .../Section/ComposeStatusSection.swift | 9 +++- .../Scene/Compose/ComposeViewController.swift | 7 ++- .../Compose/ComposeViewModel+Diffable.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 23 +++++++++ ...ComposeStatusAttachmentTableViewCell.swift | 45 +++++++++++++++++ ...> ComposeStatusContentTableViewCell.swift} | 14 ++++-- .../View/AttachmentContainerView.swift | 48 +++++++++++++++++++ .../Service/MastodonAttachmentService.swift | 27 +++++++++++ 10 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift rename Mastodon/Scene/Compose/TableViewCell/{ComposeTootContentTableViewCell.swift => ComposeStatusContentTableViewCell.swift} (84%) create mode 100644 Mastodon/Scene/Compose/View/AttachmentContainerView.swift create mode 100644 Mastodon/Service/MastodonAttachmentService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 26e99547e..5afd4f65d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -171,14 +171,17 @@ DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; - DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */; }; DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */; }; + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -450,14 +453,17 @@ DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; - DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTootContentTableViewCell.swift; sourceTree = ""; }; + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentTableViewCell.swift; sourceTree = ""; }; + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -720,12 +726,13 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, + DB49A61925FF327D00B98345 /* EmojiService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, - DB49A61925FF327D00B98345 /* EmojiService */, + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, ); path = Service; sourceTree = ""; @@ -1053,6 +1060,7 @@ isa = PBXGroup; children = ( DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, + DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, ); path = View; sourceTree = ""; @@ -1108,7 +1116,8 @@ isa = PBXGroup; children = ( DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */, - DB789A1B25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */, + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1715,6 +1724,7 @@ DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, + DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, @@ -1756,6 +1766,7 @@ 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, @@ -1798,7 +1809,7 @@ DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, - DB789A1C25F9F76A0071ACA0 /* ComposeTootContentTableViewCell.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, @@ -1859,6 +1870,7 @@ 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 79655b946..d49203c33 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -12,6 +12,7 @@ import CoreData enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) + case attachment(attachmentService: MastodonAttachmentService) } extension ComposeStatusItem: Hashable { } diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 835007dcc..f0d912ebb 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -14,6 +14,7 @@ import TwitterTextEditor enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status + case attachment } extension ComposeStatusSection { @@ -38,7 +39,7 @@ extension ComposeStatusSection { // TODO: return cell case .input(let replyToTootObjectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeTootContentTableViewCell.self), for: indexPath) as! ComposeTootContentTableViewCell + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusContentTableViewCell.self), for: indexPath) as! ComposeStatusContentTableViewCell managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { @@ -59,6 +60,10 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) return cell + case .attachment(let attachmentService): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self), for: indexPath) as! ComposeStatusAttachmentTableViewCell + + return cell } } } @@ -66,7 +71,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { static func configure( - cell: ComposeTootContentTableViewCell, + cell: ComposeStatusContentTableViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index df04b8d23..c903eb06d 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -38,7 +38,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) - tableView.register(ComposeTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeTootContentTableViewCell.self)) + tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) + tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none return tableView @@ -196,7 +197,7 @@ extension ComposeViewController { switch item { case .input: guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = tableView.cellForRow(at: indexPath) as? ComposeTootContentTableViewCell else { + let cell = tableView.cellForRow(at: indexPath) as? ComposeStatusContentTableViewCell else { continue } return cell.textEditorView @@ -401,6 +402,8 @@ extension ComposeViewController: ComposeToolbarViewDelegate { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let attachmentService = MastodonAttachmentService() + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index a3a0515e6..b58300294 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -24,7 +24,7 @@ extension ComposeViewModel { ) var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.repliedTo, .status]) + snapshot.appendSections([.repliedTo, .status, .attachment]) switch composeKind { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 743f385e5..83082d837 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -32,6 +32,9 @@ final class ComposeViewModel { // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) + // attachment + let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) + init( context: AppContext, @@ -101,6 +104,26 @@ final class ComposeViewModel { self.customEmojiViewModel.value = self.context.emojiService.dequeueCustomEmojiViewModel(for: domain) } .store(in: &disposeBag) + + // bind snapshot + attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) + var items: [ComposeStatusItem] = [] + for attachmentService in attachmentServices { + let item = ComposeStatusItem.attachment(attachmentService: attachmentService) + items.append(item) + } + snapshot.appendItems(items, toSection: .attachment) + + diffableDataSource.apply(snapshot) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift new file mode 100644 index 000000000..b63a24ebb --- /dev/null +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -0,0 +1,45 @@ +// +// ComposeStatusAttachmentTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit + +final class ComposeStatusAttachmentTableViewCell: UITableViewCell { + + static let verticalMarginHeight: CGFloat = 8 + + let attachmentContainerView = AttachmentContainerView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusAttachmentTableViewCell { + + private func _init() { + attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(attachmentContainerView) + NSLayoutConstraint.activate([ + attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentTableViewCell.verticalMarginHeight), + attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentTableViewCell.verticalMarginHeight), + attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh), + ]) + + attachmentContainerView.attachmentPreviewImageView.backgroundColor = .systemFill + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift similarity index 84% rename from Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift rename to Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift index 9f39f1989..f5f778946 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeTootContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeTootContentTableViewCell.swift +// ComposeStatusContentTableViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. @@ -9,7 +9,7 @@ import UIKit import Combine import TwitterTextEditor -final class ComposeTootContentTableViewCell: UITableViewCell { +final class ComposeStatusContentTableViewCell: UITableViewCell { var disposeBag = Set() @@ -39,7 +39,7 @@ final class ComposeTootContentTableViewCell: UITableViewCell { } -extension ComposeTootContentTableViewCell { +extension ComposeStatusContentTableViewCell { private func _init() { selectionStyle = .none @@ -56,6 +56,9 @@ extension ComposeTootContentTableViewCell { statusView.nameTrialingDotLabel.isHidden = true statusView.dateLabel.isHidden = true + statusView.setContentHuggingPriority(.defaultHigh, for: .vertical) + statusView.setContentCompressionResistancePriority(.required - 1, for: .vertical) + textEditorView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(textEditorView) NSLayoutConstraint.activate([ @@ -65,6 +68,7 @@ extension ComposeTootContentTableViewCell { contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) + textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) // TODO: @@ -78,12 +82,12 @@ extension ComposeTootContentTableViewCell { } -extension ComposeTootContentTableViewCell { +extension ComposeStatusContentTableViewCell { } // MARK: - UITextViewDelegate -extension ComposeTootContentTableViewCell: TextEditorViewChangeObserver { +extension ComposeStatusContentTableViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift new file mode 100644 index 000000000..f098d983c --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -0,0 +1,48 @@ +// +// AttachmentContainerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit + +final class AttachmentContainerView: UIView { + + let attachmentPreviewImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension AttachmentContainerView { + + private func _init() { + + attachmentPreviewImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(attachmentPreviewImageView) + NSLayoutConstraint.activate([ + attachmentPreviewImageView.topAnchor.constraint(equalTo: topAnchor), + attachmentPreviewImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + attachmentPreviewImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + attachmentPreviewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + } + +} diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift new file mode 100644 index 000000000..bdf6da657 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService.swift @@ -0,0 +1,27 @@ +// +// MastodonAttachmentService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import Combine + +final class MastodonAttachmentService { + + let identifier = UUID() + +} + +extension MastodonAttachmentService: Equatable, Hashable { + + static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + +} From 556964373e51c1e4e8f71e5852c8875270463109 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 17 Mar 2021 18:17:44 +0800 Subject: [PATCH 129/400] chore: code cleanup --- .../Scene/Compose/ComposeViewController.swift | 58 +++++++------------ 1 file changed, 20 insertions(+), 38 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index c903eb06d..0f34a9ffd 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -281,32 +281,12 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange) attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange) + // hashtag for match in highlightMatches { - // hashtag - if let name = string.substring(with: match, at: 2) { - let attachment: TextAttributes.SuffixedAttachment? - switch name { - // FIXME: - case "person": - attachment = .init(size: CGSize(width: 20.0, height: 20.0), - attachment: .image(UIImage(systemName: "person")!)) - default: - attachment = nil - } - - if let attachment = attachment { - let index = match.range.upperBound - 1 - attributedString.addAttribute( - .suffixedAttachment, - value: attachment, - range: NSRange(location: index, length: 1) - ) - } - } - // set highlight var attributes = [NSAttributedString.Key: Any]() attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + // See `traitCollectionDidChange(_:)` // set accessibility if #available(iOS 13.0, *) { @@ -320,6 +300,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttributes(attributes, range: match.range) } + // emoji let emojis = customEmojiViewModel?.emojis.value ?? [] if !emojis.isEmpty { for match in emojiMatches { @@ -367,25 +348,26 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } } + // url for match in urlMatches { - if let name = string.substring(with: match, at: 0) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) - - // set highlight - var attributes = [NSAttributedString.Key: Any]() - attributes[.foregroundColor] = Asset.Colors.Label.highlight.color - // See `traitCollectionDidChange(_:)` - // set accessibility - if #available(iOS 13.0, *) { - switch self.traitCollection.accessibilityContrast { - case .high: - attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue - default: - break - } + guard let name = string.substring(with: match, at: 0) else { continue } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) + + // set highlight + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.Label.highlight.color + + // See `traitCollectionDidChange(_:)` + // set accessibility + if #available(iOS 13.0, *) { + switch self.traitCollection.accessibilityContrast { + case .high: + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + default: + break } - attributedString.addAttributes(attributes, range: match.range) } + attributedString.addAttributes(attributes, range: match.range) } completion(attributedString) From 1b3ba1ccfb87956d0b28196ea11bce9ccdb59782 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 18 Mar 2021 15:16:35 +0800 Subject: [PATCH 130/400] feat: add pick compose image attachment logic --- Localization/app.json | 9 +- Mastodon.xcodeproj/project.pbxproj | 8 ++ .../Section/ComposeStatusSection.swift | 36 ++++- Mastodon/Generated/Assets.swift | 12 +- Mastodon/Generated/Strings.swift | 10 ++ .../plus.circle.fill.imageset/Contents.json | 14 +- .../Background/AudioPlayer/Contents.json | 9 ++ .../highlight.colorset/Contents.json | 6 +- .../danger.border.colorset/Contents.json | 20 +++ .../Background/danger.colorset/Contents.json | 20 +++ .../Contents.json | 0 .../Contents.json | 6 +- .../plus.circle.fill.imageset/Contents.json | 21 --- .../plus.circle.fill.pdf | 101 -------------- .../Connectivity/Contents.json | 9 ++ .../photo.fill.split.imageset/Contents.json | 15 ++ .../photo.fill.split.imageset/Frame 2.pdf | 114 +++++++++++++++ .../Resources/en.lproj/Localizable.strings | 4 + .../Scene/Compose/ComposeViewController.swift | 48 ++++++- .../Compose/ComposeViewModel+Diffable.swift | 6 +- ...ComposeStatusAttachmentTableViewCell.swift | 55 +++++++- ...tachmentContainerView+EmptyStateView.swift | 131 ++++++++++++++++++ .../View/AttachmentContainerView.swift | 42 ++++-- .../HomeTimelineNavigationBarView.swift | 2 +- .../Welcome/WelcomeViewController.swift | 6 +- .../View/Button/HighlightDimmableButton.swift | 5 + .../View/Button/PrimaryActionButton.swift | 4 +- .../View/Container/AudioContainerView.swift | 21 ++- .../Scene/Share/View/Content/StatusView.swift | 4 +- .../PollOptionTableViewCell.swift | 2 +- .../Service/MastodonAttachmentService.swift | 30 ++++ Mastodon/Vender/PHPickerResultLoader.swift | 72 ++++++++++ 32 files changed, 665 insertions(+), 177 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/{Button => Background/AudioPlayer}/highlight.colorset/Contents.json (74%) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/Background/{mediaTypeIndicotor.colorset => media.type.indicotor.colorset}/Contents.json (100%) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf create mode 100644 Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift create mode 100644 Mastodon/Vender/PHPickerResultLoader.swift diff --git a/Localization/app.json b/Localization/app.json index 45c771235..bc810f759 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -194,7 +194,12 @@ "new_reply": "New Reply" }, "content_input_placeholder": "Type or paste what's on your mind", - "compose_action": "Publish" + "compose_action": "Publish", + "attachment": { + "photo": "photo", + "video": "video", + "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon." + } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5afd4f65d..99d1cdf2e 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -212,6 +212,8 @@ DB98338725C945ED00AD9700 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338525C945ED00AD9700 /* Strings.swift */; }; DB98338825C945ED00AD9700 /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98338625C945ED00AD9700 /* Assets.swift */; }; DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; + DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; + DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -496,6 +498,8 @@ DB98338525C945ED00AD9700 /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; DB98338625C945ED00AD9700 /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = ""; }; + DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -718,6 +722,7 @@ 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */, DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, + DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, ); path = Vender; sourceTree = ""; @@ -1061,6 +1066,7 @@ children = ( DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, + DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, ); path = View; sourceTree = ""; @@ -1799,6 +1805,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, @@ -1834,6 +1841,7 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, + DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index f0d912ebb..a99e1f49a 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -10,6 +10,7 @@ import Combine import CoreData import CoreDataStack import TwitterTextEditor +import AlamofireImage enum ComposeStatusSection: Equatable, Hashable { case repliedTo @@ -30,9 +31,10 @@ extension ComposeStatusSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentTableViewCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate, weak composeStatusAttachmentTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell @@ -62,7 +64,35 @@ extension ComposeStatusSection { return cell case .attachment(let attachmentService): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self), for: indexPath) as! ComposeStatusAttachmentTableViewCell - + cell.delegate = composeStatusAttachmentTableViewCellDelegate + attachmentService.imageData + .receive(on: DispatchQueue.main) + .sink { imageData in + guard let imageData = imageData, + let image = UIImage(data: imageData) else { + let placeholder = UIImage.placeholder( + size: cell.attachmentContainerView.previewImageView.frame.size, + color: Asset.Colors.Background.systemGroupedBackground.color + ) + .af.imageRounded( + withCornerRadius: AttachmentContainerView.containerViewCornerRadius + ) + cell.attachmentContainerView.previewImageView.image = placeholder + return + } + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.previewImageView.image = image + .af.imageAspectScaled(toFill: cell.attachmentContainerView.previewImageView.frame.size) + .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) + } + .store(in: &cell.disposeBag) + attachmentService.error + .receive(on: DispatchQueue.main) + .sink { error in + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + } + .store(in: &cell.disposeBag) return cell } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 2133c5aa3..ba58cb3f1 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -30,11 +30,16 @@ internal enum Asset { } internal enum Colors { internal enum Background { + internal enum AudioPlayer { + internal static let highlight = ColorAsset(name: "Colors/Background/AudioPlayer/highlight") + } internal enum Poll { internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") } - internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/mediaTypeIndicotor") + internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") + internal static let danger = ColorAsset(name: "Colors/Background/danger") + internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") @@ -45,7 +50,6 @@ internal enum Asset { internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") - internal static let highlight = ColorAsset(name: "Colors/Button/highlight") internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { @@ -79,10 +83,12 @@ internal enum Asset { internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") - internal static let plusCircleFill = ImageAsset(name: "Colors/plus.circle.fill") internal static let systemGreen = ColorAsset(name: "Colors/system.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } + internal enum Connectivity { + internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7079b2970..59ee4a49b 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -144,6 +144,16 @@ internal enum L10n { internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") /// Type or paste what's on your mind internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") + internal enum Attachment { + /// This %@ is broken and can't be\nuploaded to Mastodon. + internal static func attachmentBroken(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) + } + /// photo + internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") + /// video + internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") + } internal enum Title { /// New Post internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json index 580a3f7a0..40480a161 100644 --- a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.fill.imageset/Contents.json @@ -2,20 +2,14 @@ "images" : [ { "filename" : "plus.circle.fill.pdf", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true } } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json similarity index 74% rename from Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json index 03a422b00..2e1ce5f3a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/AudioPlayer/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.839", - "green" : "0.573", - "red" : "0.204" + "blue" : "0.851", + "green" : "0.565", + "red" : "0.169" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json new file mode 100644 index 000000000..dabccc33e --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.353", + "green" : "0.251", + "red" : "0.875" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json new file mode 100644 index 000000000..b77cb3c75 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "90", + "green" : "64", + "red" : "223" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/mediaTypeIndicotor.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/media.type.indicotor.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index d097fec40..edc0dce9a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE1", - "red" : "0xD9" + "blue" : "232", + "green" : "225", + "red" : "217" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json deleted file mode 100644 index 580a3f7a0..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "plus.circle.fill.pdf", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf b/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf deleted file mode 100644 index f4a613417..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/plus.circle.fill.imageset/plus.circle.fill.pdf +++ /dev/null @@ -1,101 +0,0 @@ -%PDF-1.7 - -1 0 obj - << >> -endobj - -2 0 obj - << /Length 3 0 R >> -stream -/DeviceRGB CS -/DeviceRGB cs -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -1.000000 1.000000 1.000000 scn -30.000000 15.000000 m -30.000000 6.715729 23.284271 0.000000 15.000000 0.000000 c -6.715729 0.000000 0.000000 6.715729 0.000000 15.000000 c -0.000000 23.284271 6.715729 30.000000 15.000000 30.000000 c -23.284271 30.000000 30.000000 23.284271 30.000000 15.000000 c -h -f -n -Q -q -1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm -0.000000 0.000000 0.000000 scn -15.009004 0.000000 m -23.233341 0.000000 30.000000 6.766640 30.000000 15.009003 c -30.000000 23.233379 23.233341 30.000000 14.991017 30.000000 c -6.766642 30.000000 0.000000 23.233379 0.000000 15.009003 c -0.000000 6.766640 6.766643 0.000000 15.009004 0.000000 c -h -8.098384 15.009003 m -8.098384 16.034798 8.836217 16.790653 9.844025 16.790653 c -13.209368 16.790653 l -13.209368 20.155996 l -13.209368 21.163769 13.965223 21.919624 14.991017 21.919624 c -16.016811 21.919624 16.772667 21.163769 16.772667 20.155996 c -16.772667 16.790653 l -20.137974 16.790653 l -21.163769 16.790653 21.901638 16.034798 21.901638 15.009003 c -21.901638 13.983208 21.163769 13.227352 20.137974 13.227352 c -16.772667 13.227352 l -16.772667 9.862047 l -16.772667 8.854239 16.016811 8.098381 14.991017 8.098381 c -13.965223 8.098381 13.209368 8.854239 13.209368 9.862047 c -13.209368 13.227352 l -9.844025 13.227352 l -8.836217 13.227352 8.098384 13.983208 8.098384 15.009003 c -h -f -n -Q - -endstream -endobj - -3 0 obj - 1426 -endobj - -4 0 obj - << /Annots [] - /Type /Page - /MediaBox [ 0.000000 0.000000 30.000000 30.000000 ] - /Resources 1 0 R - /Contents 2 0 R - /Parent 5 0 R - >> -endobj - -5 0 obj - << /Kids [ 4 0 R ] - /Count 1 - /Type /Pages - >> -endobj - -6 0 obj - << /Type /Catalog - /Pages 5 0 R - >> -endobj - -xref -0 7 -0000000000 65535 f -0000000010 00000 n -0000000034 00000 n -0000001516 00000 n -0000001539 00000 n -0000001712 00000 n -0000001786 00000 n -trailer -<< /ID [ (some) (id) ] - /Root 6 0 R - /Size 7 ->> -startxref -1845 -%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json new file mode 100644 index 000000000..9c640adf5 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Frame 2.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf new file mode 100644 index 000000000..4ce898753 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Connectivity/photo.fill.split.imageset/Frame 2.pdf @@ -0,0 +1,114 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +0.992546 -0.121869 0.121869 0.992546 42.624641 7.462139 cm +0.000000 0.000000 0.000000 scn +29.841717 4.404652 m +10.813622 4.404652 l +5.729721 9.441498 l +29.810324 9.441498 l +32.782593 9.441498 34.628483 11.256016 34.628483 14.259354 c +34.628483 19.077240 l +20.237179 32.404518 l +18.766808 33.781090 16.983574 34.438072 15.262939 34.438072 c +13.481962 34.438072 11.763362 33.813934 10.231857 32.441067 c +0.000000 39.493633 l +11.853184 50.706104 l +1.586006 62.000000 l +29.841717 62.000000 l +36.411587 62.000000 39.665127 58.746330 39.665127 52.301659 c +39.665127 14.102936 l +39.665127 7.658268 36.411587 4.404652 29.841717 4.404652 c +h +f* +n +Q +q +1.000000 0.000000 -0.000000 1.000000 6.000000 11.404663 cm +0.000000 0.000000 0.000000 scn +35.690556 57.595337 m +9.823408 57.595337 l +3.284870 57.595337 0.000000 54.372997 0.000000 47.896996 c +0.000000 9.698273 l +0.000000 3.222317 3.284870 -0.000011 9.823408 -0.000011 c +44.918179 -0.000011 l +39.834278 5.036835 l +9.886006 5.036835 l +6.851334 5.036835 5.036836 6.851357 5.036836 9.917267 c +5.036836 11.825638 l +14.641250 20.209938 l +16.017820 21.430046 17.519461 22.055767 18.896032 22.055767 c +20.428938 22.055767 22.024504 21.430050 23.401012 20.147408 c +29.376427 14.766380 l +44.330532 28.031185 l +44.332489 28.032942 44.334446 28.034697 44.336403 28.036451 c +34.104435 35.089096 l +45.957619 46.301567 l +35.690556 57.595337 l +h +15.736227 35.758499 m +15.736227 31.503782 19.208826 28.031185 23.463608 28.031185 c +27.687059 28.031185 31.159658 31.503782 31.159658 35.758499 c +31.159658 39.981949 27.687059 43.485878 23.463608 43.485878 c +19.208826 43.485878 15.736227 39.981949 15.736227 35.758499 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1681 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 92.000000 76.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001771 00000 n +0000001794 00000 n +0000001967 00000 n +0000002041 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2100 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 92e0161a9..64a3e17e8 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -38,6 +38,10 @@ "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be +uploaded to Mastodon."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; "Scene.Compose.Title.NewPost" = "New Post"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 0f34a9ffd..7fd5dfd14 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -8,8 +8,9 @@ import os.log import UIKit import Combine -import TwitterTextEditor +import PhotosUI import Kingfisher +import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { @@ -42,6 +43,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false return tableView }() @@ -57,6 +59,16 @@ final class ComposeViewController: UIViewController, NeedsDependency { return backgroundView }() + lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 4 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + } extension ComposeViewController { @@ -109,7 +121,8 @@ extension ComposeViewController { viewModel.setupDiffableDataSource( for: tableView, dependency: self, - textEditorViewTextAttributesDelegate: self + textEditorViewTextAttributesDelegate: self, + composeStatusAttachmentTableViewCellDelegate: self ) // respond scrollView overlap change @@ -377,15 +390,12 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } - - // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let attachmentService = MastodonAttachmentService() - viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + present(imagePicker, animated: true, completion: nil) } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { @@ -431,3 +441,29 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } } + +// MARK: - PHPickerViewControllerDelegate +extension ComposeViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + let attachmentServices = results.map { MastodonAttachmentService(pickerResult: $0) } + viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices + } +} + +// MARK: - ComposeStatusAttachmentTableViewCellDelegate +extension ComposeViewController: ComposeStatusAttachmentTableViewCellDelegate { + + func composeStatusAttachmentTableViewCell(_ cell: ComposeStatusAttachmentTableViewCell, removeButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .attachment(attachmentService) = item else { return } + + var attachmentServices = viewModel.attachmentServices.value + guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } + attachmentServices.remove(at: index) + viewModel.attachmentServices.value = attachmentServices + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index b58300294..d989d6f46 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -13,14 +13,16 @@ extension ComposeViewModel { func setupDiffableDataSource( for tableView: UITableView, dependency: NeedsDependency, - textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate + textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentTableViewCellDelegate ) { diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( for: tableView, dependency: dependency, managedObjectContext: context.managedObjectContext, composeKind: composeKind, - textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate + textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, + composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate ) var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift index b63a24ebb..88ae255fc 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -5,13 +5,44 @@ // Created by MainasuK Cirno on 2021-3-17. // +import os.log import UIKit +import Combine + +protocol ComposeStatusAttachmentTableViewCellDelegate: class { + func composeStatusAttachmentTableViewCell(_ cell: ComposeStatusAttachmentTableViewCell, removeButtonDidPressed button: UIButton) +} final class ComposeStatusAttachmentTableViewCell: UITableViewCell { - static let verticalMarginHeight: CGFloat = 8 + var disposeBag = Set() + + static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentTableViewCell.removeButtonSize.height * 0.5 + static let removeButtonSize = CGSize(width: 22, height: 22) + + weak var delegate: ComposeStatusAttachmentTableViewCellDelegate? let attachmentContainerView = AttachmentContainerView() + let removeButton: UIButton = { + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) + let image = UIImage(systemName: "minus")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 14, weight: .bold)) + button.tintColor = .white + button.setImage(image, for: .normal) + button.setBackgroundImage(.placeholder(color: Asset.Colors.Background.danger.color), for: .normal) + button.layer.masksToBounds = true + button.layer.cornerRadius = ComposeStatusAttachmentTableViewCell.removeButtonSize.width * 0.5 + button.layer.borderColor = Asset.Colors.Background.dangerBorder.color.cgColor + button.layer.borderWidth = 1 + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + + attachmentContainerView.activityIndicatorView.startAnimating() + delegate = nil + } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -28,6 +59,8 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { extension ComposeStatusAttachmentTableViewCell { private func _init() { + selectionStyle = .none + attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(attachmentContainerView) NSLayoutConstraint.activate([ @@ -38,8 +71,26 @@ extension ComposeStatusAttachmentTableViewCell { attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh), ]) - attachmentContainerView.attachmentPreviewImageView.backgroundColor = .systemFill + removeButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(removeButton) + NSLayoutConstraint.activate([ + removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor), + removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor), + removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentTableViewCell.removeButtonSize.width).priority(.defaultHigh), + removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentTableViewCell.removeButtonSize.height).priority(.defaultHigh), + ]) + + removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentTableViewCell.removeButtonDidPressed(_:)), for: .touchUpInside) } } + +extension ComposeStatusAttachmentTableViewCell { + + @objc private func removeButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusAttachmentTableViewCell(self, removeButtonDidPressed: sender) + } + +} diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift new file mode 100644 index 000000000..8a0efa800 --- /dev/null +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -0,0 +1,131 @@ +// +// AttachmentContainerView+EmptyStateView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import UIKit + +extension AttachmentContainerView { + final class EmptyStateView: UIView { + + static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) + static let videoSplashImage: UIImage = { + let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) + return image + }() + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage + return imageView + }() + let label: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .body) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) + label.numberOfLines = 2 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + } +} + +extension AttachmentContainerView.EmptyStateView { + private func _init() { + layer.masksToBounds = true + layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + layer.cornerCurve = .continuous + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + let topPaddingView = UIView() + let middlePaddingView = UIView() + let bottomPaddingView = UIView() + + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(topPaddingView) + imageView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(imageView) + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), + ]) + imageView.setContentHuggingPriority(.required - 1, for: .vertical) + middlePaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(middlePaddingView) + stackView.addArrangedSubview(label) + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 205)) + UIViewPreview(width: 375) { + let emptyStateView = AttachmentContainerView.EmptyStateView() + emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage + emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) + + NSLayoutConstraint.activate([ + emptyStateView.heightAnchor.constraint(equalToConstant: 205) + ]) + return emptyStateView + } + .previewLayout(.fixed(width: 375, height: 205)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index f098d983c..d61f2e672 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -8,16 +8,20 @@ import UIKit final class AttachmentContainerView: UIView { + + static let containerViewCornerRadius: CGFloat = 4 - let attachmentPreviewImageView: UIImageView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + + let previewImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.layer.masksToBounds = true - imageView.layer.cornerRadius = 4 - imageView.layer.cornerCurve = .continuous return imageView }() + let emptyStateView = AttachmentContainerView.EmptyStateView() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -33,16 +37,34 @@ final class AttachmentContainerView: UIView { extension AttachmentContainerView { private func _init() { - - attachmentPreviewImageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(attachmentPreviewImageView) + previewImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(previewImageView) NSLayoutConstraint.activate([ - attachmentPreviewImageView.topAnchor.constraint(equalTo: topAnchor), - attachmentPreviewImageView.leadingAnchor.constraint(equalTo: leadingAnchor), - attachmentPreviewImageView.trailingAnchor.constraint(equalTo: trailingAnchor), - attachmentPreviewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + previewImageView.topAnchor.constraint(equalTo: topAnchor), + previewImageView.leadingAnchor.constraint(equalTo: leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: trailingAnchor), + emptyStateView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), + ]) + + emptyStateView.isHidden = true + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index c19d45e47..dc7b8a47b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -22,7 +22,7 @@ final class HomeTimelineNavigationBarView { }() static let newPostsView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.highlight.color) + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color) let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts) HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) return view diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 63c1e4217..e415c5737 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -37,10 +37,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency { private(set) lazy var signUpButton: PrimaryActionButton = { let button = PrimaryActionButton() button.setTitle(L10n.Common.Controls.Actions.signUp, for: .normal) - let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.highlight.color + let backgroundImageColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? .white : Asset.Colors.Button.normal.color button.setBackgroundImage(.placeholder(color: backgroundImageColor), for: .normal) button.setBackgroundImage(.placeholder(color: backgroundImageColor.withAlphaComponent(0.9)), for: .highlighted) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.highlight.color : UIColor.white + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? Asset.Colors.Button.normal.color : UIColor.white button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -50,7 +50,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) button.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) - let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.highlight.color + let titleColor: UIColor = traitCollection.userInterfaceIdiom == .phone ? UIColor.white.withAlphaComponent(0.8) : Asset.Colors.Button.normal.color button.setTitleColor(titleColor, for: .normal) button.translatesAutoresizingMaskIntoConstraints = false return button diff --git a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift index 3eb916f26..5202d376a 100644 --- a/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift +++ b/Mastodon/Scene/Share/View/Button/HighlightDimmableButton.swift @@ -9,6 +9,8 @@ import UIKit final class HighlightDimmableButton: UIButton { + var expandEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -19,6 +21,9 @@ final class HighlightDimmableButton: UIButton { _init() } + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return bounds.inset(by: expandEdgeInsets).contains(point) + } override var isHighlighted: Bool { didSet { diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index 0d68cd74d..aa36fd237 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -38,8 +38,8 @@ extension PrimaryActionButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) setTitleColor(.white, for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color), for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.highlight.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color), for: .normal) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) applyCornerRadius(radius: 10) } diff --git a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift index 980e5ae87..336fade8f 100644 --- a/Mastodon/Scene/Share/View/Container/AudioContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/AudioContainerView.swift @@ -22,7 +22,7 @@ final class AudioContainerView: UIView { stackView.isLayoutMarginsRelativeArrangement = true stackView.layer.cornerRadius = AudioContainerView.cornerRadius stackView.clipsToBounds = true - stackView.backgroundColor = Asset.Colors.Button.highlight.color + stackView.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color stackView.translatesAutoresizingMaskIntoConstraints = false return stackView }() @@ -31,7 +31,7 @@ final class AudioContainerView: UIView { let view = UIView() view.layer.cornerRadius = 16 view.clipsToBounds = true - view.backgroundColor = Asset.Colors.Button.highlight.color + view.backgroundColor = Asset.Colors.Background.AudioPlayer.highlight.color view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -109,3 +109,20 @@ extension AudioContainerView { ]) } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct AudioContainerView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + AudioContainerView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 3987aa5fc..0ceb248a6 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -151,8 +151,8 @@ final class StatusView: UIView { let button = HitTestExpandedButton() button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold)) button.setTitle(L10n.Common.Controls.Status.Poll.vote, for: .normal) - button.setTitleColor(Asset.Colors.Button.highlight.color, for: .normal) - button.setTitleColor(Asset.Colors.Button.highlight.color.withAlphaComponent(0.8), for: .highlighted) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.8), for: .highlighted) button.setTitleColor(Asset.Colors.Button.disabled.color, for: .disabled) button.isEnabled = false return button diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 7aa7ef41d..2fd3a023d 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -35,7 +35,7 @@ final class PollOptionTableViewCell: UITableViewCell { let imageView = UIImageView() let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! imageView.image = image.withRenderingMode(.alwaysTemplate) - imageView.tintColor = Asset.Colors.Button.highlight.color + imageView.tintColor = Asset.Colors.Button.normal.color return imageView }() diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift index bdf6da657..e845d2d7e 100644 --- a/Mastodon/Service/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService.swift @@ -7,11 +7,41 @@ import UIKit import Combine +import PhotosUI final class MastodonAttachmentService { + var disposeBag = Set() + let identifier = UUID() + // input + let pickerResult: PHPickerResult + + // output + let imageData = CurrentValueSubject(nil) + let error = CurrentValueSubject(nil) + + init(pickerResult: PHPickerResult) { + self.pickerResult = pickerResult + // end init + + PHPickerResultLoader.loadImageData(from: pickerResult) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + self.imageData.value = imageData + } + .store(in: &disposeBag) + } + } extension MastodonAttachmentService: Equatable, Hashable { diff --git a/Mastodon/Vender/PHPickerResultLoader.swift b/Mastodon/Vender/PHPickerResultLoader.swift new file mode 100644 index 000000000..7e083001c --- /dev/null +++ b/Mastodon/Vender/PHPickerResultLoader.swift @@ -0,0 +1,72 @@ +// +// PHPickerResultLoader.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import Combine +import MobileCoreServices +import PhotosUI + +// load image with low memory usage +// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/ +enum PHPickerResultLoader { + + static func loadImageData(from result: PHPickerResult) -> Future { + Future { promise in + result.itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + if let error = error { + promise(.failure(error)) + return + } + + guard let url = url else { + promise(.success(nil)) + return + } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + return + } + + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 4096, + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + return + } + + let data = NSMutableData() + guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else { + promise(.success(nil)) + return + } + + let isPNG: Bool = { + guard let utType = cgImage.utType else { return false } + return (utType as String) == UTType.png.identifier + }() + + let destinationProperties = [ + kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75 + ] as CFDictionary + + CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) + CGImageDestinationFinalize(imageDestination) + + let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load image %s", ((#file as NSString).lastPathComponent), #line, #function, dataSize) + + promise(.success(data as Data)) + } + } + } + +} From 296d29f3e0e466b05c5d0ad32c6aa8622d0d09fd Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 18 Mar 2021 17:33:07 +0800 Subject: [PATCH 131/400] feat: implement status publish API --- Localization/app.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 25 ++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ .../Section/ComposeStatusSection.swift | 16 ++++ Mastodon/Generated/Strings.swift | 4 + .../danger.border.colorset/Contents.json | 6 +- .../Resources/en.lproj/Localizable.strings | 2 + .../Scene/Compose/ComposeViewController.swift | 48 +++++++++- .../ComposeViewModel+PublishState.swift | 88 +++++++++++++++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 18 +++- .../View/AttachmentContainerView.swift | 56 ++++++++++++ .../APIService/APIService+Status.swift | 33 +++++++ .../Service/MastodonAttachmentService.swift | 1 + .../API/Mastodon+API+Statuses.swift | 59 +++++++++++++ README.md | 1 + 15 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift create mode 100644 Mastodon/Service/APIService/APIService+Status.swift diff --git a/Localization/app.json b/Localization/app.json index bc810f759..a23e5f53e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -198,7 +198,9 @@ "attachment": { "photo": "photo", "video": "video", - "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon." + "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", + "description_photo": "Describe photo for low vision people...", + "description_video": "Describe what’s happening for low vision people..." } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 99d1cdf2e..c898d3efb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -214,6 +214,9 @@ DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -500,6 +503,8 @@ DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; + DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -530,6 +535,7 @@ DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, @@ -1029,6 +1035,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, + DB9A488326034BD7008B817C /* APIService+Status.swift */, ); path = APIService; sourceTree = ""; @@ -1113,6 +1120,7 @@ DB789A2125F9F76D0071ACA0 /* TableViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, ); path = Compose; @@ -1407,6 +1415,7 @@ 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, DB6672A225F9FDE500D60309 /* TwitterTextEditor */, + DB9A487D2603456B008B817C /* UITextView+Placeholder */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1537,6 +1546,7 @@ 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1748,6 +1758,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, + DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, @@ -1818,6 +1829,7 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, + DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, @@ -2489,6 +2501,14 @@ minimumVersion = 1.0.0; }; }; + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.4.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2536,6 +2556,11 @@ package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { + isa = XCSwiftPackageProductDependency; + package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; + productName = "UITextView+Placeholder"; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 21afdd4cd..212a2d7a6 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -108,6 +108,15 @@ "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", "version": "1.0.0" } + }, + { + "package": "UITextView+Placeholder", + "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", + "state": { + "branch": null, + "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", + "version": "1.4.1" + } } ] }, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index a99e1f49a..772a327b2 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -42,6 +42,7 @@ extension ComposeStatusSection { return cell case .input(let replyToTootObjectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusContentTableViewCell.self), for: indexPath) as! ComposeStatusContentTableViewCell + cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { @@ -55,15 +56,19 @@ extension ComposeStatusSection { cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate // self size input cell cell.composeContent + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in tableView.beginUpdates() tableView.endUpdates() + // bind input data + attribute.composeContent.value = text } .store(in: &cell.disposeBag) return cell case .attachment(let attachmentService): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self), for: indexPath) as! ComposeStatusAttachmentTableViewCell + cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value cell.delegate = composeStatusAttachmentTableViewCellDelegate attachmentService.imageData .receive(on: DispatchQueue.main) @@ -93,6 +98,17 @@ extension ComposeStatusSection { cell.attachmentContainerView.emptyStateView.isHidden = error == nil } .store(in: &cell.disposeBag) + NotificationCenter.default.publisher( + for: UITextView.textDidChangeNotification, + object: cell.attachmentContainerView.descriptionTextView + ) + .receive(on: DispatchQueue.main) + .sink { notification in + guard let textField = notification.object as? UITextView else { return } + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + attachmentService.description.value = text + } + .store(in: &cell.disposeBag) return cell } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 59ee4a49b..4bfb7bbf8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -149,6 +149,10 @@ internal enum L10n { internal static func attachmentBroken(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Compose.Attachment.AttachmentBroken", String(describing: p1)) } + /// Describe photo for low vision people... + internal static let descriptionPhoto = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionPhoto") + /// Describe what’s happening for low vision people... + internal static let descriptionVideo = L10n.tr("Localizable", "Scene.Compose.Attachment.DescriptionVideo") /// photo internal static let photo = L10n.tr("Localizable", "Scene.Compose.Attachment.Photo") /// video diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json index dabccc33e..bc9f94fcc 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/danger.border.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.353", - "green" : "0.251", - "red" : "0.875" + "blue" : "66", + "green" : "46", + "red" : "163" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 64a3e17e8..e751f6204 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -40,6 +40,8 @@ "Common.Countable.Photo.Single" = "photo"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; "Scene.Compose.Attachment.Photo" = "photo"; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 7fd5dfd14..2870239ec 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -22,7 +22,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { private var suffixedAttachmentViews: [UIView] = [] - let composeTootBarButtonItem: UIBarButtonItem = { + let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) @@ -32,7 +32,10 @@ final class ComposeViewController: UIViewController, NeedsDependency { button.setTitleColor(.white, for: .normal) button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16) button.adjustsImageWhenHighlighted = false - let barButtonItem = UIBarButtonItem(customView: button) + return button + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(customView: publishButton) return barButtonItem }() @@ -85,7 +88,8 @@ extension ComposeViewController { .store(in: &disposeBag) view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) - navigationItem.rightBarButtonItem = composeTootBarButtonItem + navigationItem.rightBarButtonItem = publishBarButtonItem + publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) @@ -171,7 +175,7 @@ extension ComposeViewController { viewModel.isComposeTootBarButtonItemEnabled .receive(on: DispatchQueue.main) - .assign(to: \.isEnabled, on: composeTootBarButtonItem) + .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) // bind custom emojis @@ -187,6 +191,16 @@ extension ComposeViewController { self.textEditorView()?.setNeedsUpdateTextAttributes() }) .store(in: &disposeBag) + + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -241,6 +255,22 @@ extension ComposeViewController { alertController.addAction(cancelAction) present(alertController, animated: true, completion: nil) } + + private func resetImagePicker() { + var configuration = PHPickerConfiguration() + configuration.filter = .images + let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count) + configuration.selectionLimit = selectionLimit + + imagePicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } + } extension ComposeViewController { @@ -254,6 +284,16 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { + // TODO: handle error + return + } + + dismiss(animated: true, completion: nil) + } + } // MARK: - TextEditorViewTextAttributesDelegate diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift new file mode 100644 index 000000000..0033bff37 --- /dev/null +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -0,0 +1,88 @@ +// +// ComposeViewModel+PublishState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +extension ComposeViewModel { + class PublishState: GKState { + weak var viewModel: ComposeViewModel? + + init(viewModel: ComposeViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension ComposeViewModel.PublishState { + class Initial: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Publishing.self + } + } + + class Publishing: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let mastodonAuthenticationBox = viewModel.activeAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: viewModel.composeStatusAttribute.composeContent.value, + mediaIDs: nil + ) + viewModel.context.apiService.publishStatus( + domain: mastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Finish.self) + } + } receiveValue: { status in + + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Publishing.self || stateClass == Finish.self + } + } + + class Finish: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 83082d837..67d3adb42 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -9,6 +9,7 @@ import UIKit import Combine import CoreData import CoreDataStack +import GameplayKit final class ComposeViewModel { @@ -18,11 +19,22 @@ final class ComposeViewModel { let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() - let composeContent = CurrentValueSubject("") let activeAuthentication: CurrentValueSubject + let activeAuthenticationBox: CurrentValueSubject // output var diffableDataSource: UITableViewDiffableDataSource! + private(set) lazy var publishStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + PublishState.Initial(viewModel: self), + PublishState.Publishing(viewModel: self), + PublishState.Fail(viewModel: self), + PublishState.Finish(viewModel: self), + ]) + stateMachine.enter(PublishState.Initial.self) + return stateMachine + }() // UI & UX let title: CurrentValueSubject @@ -47,12 +59,16 @@ final class ComposeViewModel { case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) + self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) .store(in: &disposeBag) + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeAuthenticationBox) + .store(in: &disposeBag) // bind avatar and names activeAuthentication diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index d61f2e672..5bf7020a3 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -6,11 +6,14 @@ // import UIKit +import UITextView_Placeholder final class AttachmentContainerView: UIView { static let containerViewCornerRadius: CGFloat = 4 + var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? + let activityIndicatorView = UIActivityIndicatorView(style: .medium) let previewImageView: UIImageView = { @@ -21,6 +24,34 @@ final class AttachmentContainerView: UIView { }() let emptyStateView = AttachmentContainerView.EmptyStateView() + let descriptionBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + view.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 5, right: 8) + return view + }() + let descriptionBackgroundGradientLayer: CAGradientLayer = { + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.black.withAlphaComponent(0.0).cgColor, UIColor.black.withAlphaComponent(0.69).cgColor] + gradientLayer.locations = [0.0, 1.0] + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + return gradientLayer + }() + let descriptionTextView: UITextView = { + let textView = UITextView() + textView.showsVerticalScrollIndicator = false + textView.backgroundColor = .clear + textView.textColor = .white + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto + textView.placeholderColor = Asset.Colors.Label.secondary.color + return textView + }() override init(frame: CGRect) { super.init(frame: frame) @@ -46,6 +77,29 @@ extension AttachmentContainerView { previewImageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + descriptionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(descriptionBackgroundView) + NSLayoutConstraint.activate([ + descriptionBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + descriptionBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + descriptionBackgroundView.bottomAnchor.constraint(equalTo: bottomAnchor), + descriptionBackgroundView.heightAnchor.constraint(equalTo: heightAnchor, multiplier: 0.3), + ]) + descriptionBackgroundView.layer.addSublayer(descriptionBackgroundGradientLayer) + descriptionBackgroundViewFrameObservation = descriptionBackgroundView.observe(\.bounds, options: [.initial, .new]) { [weak self] _, _ in + guard let self = self else { return } + self.descriptionBackgroundGradientLayer.frame = self.descriptionBackgroundView.bounds + } + + descriptionTextView.translatesAutoresizingMaskIntoConstraints = false + descriptionBackgroundView.addSubview(descriptionTextView) + NSLayoutConstraint.activate([ + descriptionTextView.leadingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.leadingAnchor), + descriptionTextView.trailingAnchor.constraint(equalTo: descriptionBackgroundView.layoutMarginsGuide.trailingAnchor), + descriptionBackgroundView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: descriptionTextView.bottomAnchor), + descriptionTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 36), + ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false addSubview(emptyStateView) NSLayoutConstraint.activate([ @@ -62,6 +116,8 @@ extension AttachmentContainerView { activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), ]) + descriptionBackgroundView.overrideUserInterfaceStyle = .dark + emptyStateView.isHidden = true activityIndicatorView.hidesWhenStopped = true activityIndicatorView.startAnimating() diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift new file mode 100644 index 000000000..dee775476 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -0,0 +1,33 @@ +// +// APIService+Status.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func publishStatus( + domain: String, + query: Mastodon.API.Statuses.PublishStatusQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Statuses.publishStatus( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift index e845d2d7e..e29a04408 100644 --- a/Mastodon/Service/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService.swift @@ -17,6 +17,7 @@ final class MastodonAttachmentService { // input let pickerResult: PHPickerResult + let description = CurrentValueSubject(nil) // output let imageData = CurrentValueSubject(nil) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index f01e6cb47..7283411f5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -6,3 +6,62 @@ // import Foundation +import Combine + +extension Mastodon.API.Statuses { + + static func publishNewStatusEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses") + } + + /// Publish new status + /// + /// Post a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `PublishStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func publishStatus( + session: URLSession, + domain: String, + query: PublishStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: publishNewStatusEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct PublishStatusQuery: Codable, PostQuery { + public let status: String? + public let mediaIDs: [String]? + + enum CodingKeys: String, CodingKey { + case status + case mediaIDs = "media_ids" + } + + public init(status: String?, mediaIDs: [String]?) { + self.status = status + self.mediaIDs = mediaIDs + } + } + +} diff --git a/README.md b/README.md index 53e3bf498..61f142bb8 100644 --- a/README.md +++ b/README.md @@ -54,5 +54,6 @@ arch -x86_64 pod install - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) +- [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) ## License From 75d10b76c8c63f31d28e7d6a8a9a08875759b796 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 18 Mar 2021 19:42:26 +0800 Subject: [PATCH 132/400] feat: implement image upload logic --- Mastodon.xcodeproj/project.pbxproj | 18 ++- .../Scene/Compose/ComposeViewController.swift | 13 ++- .../ComposeViewModel+PublishState.swift | 18 +-- Mastodon/Scene/Compose/ComposeViewModel.swift | 41 +++++-- .../Service/APIService/APIService+Media.swift | 29 +++++ .../APIService/APIService+Status.swift | 4 - .../Service/MastodonAttachmentService.swift | 58 ---------- ...astodonAttachmentService+UploadState.swift | 104 +++++++++++++++++ .../MastodonAttachmentService.swift | 107 ++++++++++++++++++ .../Mastodon+API+Account+Credentials.swift | 1 + .../MastodonSDK/API/Mastodon+API+Media.swift | 88 ++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 12 files changed, 401 insertions(+), 81 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+Media.swift delete mode 100644 Mastodon/Service/MastodonAttachmentService.swift create mode 100644 Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift create mode 100644 Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c898d3efb..684141b1b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -217,6 +217,8 @@ DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; + DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -505,6 +507,8 @@ DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; + DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = ""; }; + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -738,12 +742,12 @@ children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, DB49A61925FF327D00B98345 /* EmojiService */, + DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, - DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, ); path = Service; sourceTree = ""; @@ -1036,6 +1040,7 @@ DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, + DB9A488F26035963008B817C /* APIService+Media.swift */, ); path = APIService; sourceTree = ""; @@ -1292,6 +1297,15 @@ path = Generated; sourceTree = ""; }; + DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { + isa = PBXGroup; + children = ( + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */, + ); + path = MastodonAttachmentService; + sourceTree = ""; + }; DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( @@ -1797,6 +1811,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -1895,6 +1910,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 2870239ec..254cd5835 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -173,7 +173,7 @@ extension ComposeViewController { }) .store(in: &disposeBag) - viewModel.isComposeTootBarButtonItemEnabled + viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) @@ -486,7 +486,16 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { extension ComposeViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) - let attachmentServices = results.map { MastodonAttachmentService(pickerResult: $0) } + + let attachmentServices: [MastodonAttachmentService] = results.map { result in + let service = MastodonAttachmentService( + context: context, + pickerResult: result, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + service.delegate = viewModel + return service + } viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 0033bff37..8b1cc0c97 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -7,8 +7,7 @@ import os.log import Foundation -import CoreData -import CoreDataStack +import Combine import GameplayKit import MastodonSDK @@ -34,6 +33,9 @@ extension ComposeViewModel.PublishState { } class Publishing: ComposeViewModel.PublishState { + + var publishingSubscription: AnyCancellable? + override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Finish.self } @@ -46,11 +48,14 @@ extension ComposeViewModel.PublishState { return } + let mediaIDs = viewModel.attachmentServices.value.compactMap { attachmentService in + attachmentService.attachment.value?.id + } let query = Mastodon.API.Statuses.PublishStatusQuery( status: viewModel.composeStatusAttribute.composeContent.value, - mediaIDs: nil + mediaIDs: mediaIDs ) - viewModel.context.apiService.publishStatus( + publishingSubscription = viewModel.context.apiService.publishStatus( domain: mastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: mastodonAuthenticationBox @@ -65,10 +70,9 @@ extension ComposeViewModel.PublishState { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) stateMachine.enter(Finish.self) } - } receiveValue: { status in - + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) } - .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 67d3adb42..2d6dc728d 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -39,7 +39,7 @@ final class ComposeViewModel { // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) - let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + let isPublishBarButtonItemEnabled = CurrentValueSubject(false) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) @@ -47,7 +47,6 @@ final class ComposeViewModel { // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) - init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -89,14 +88,30 @@ final class ComposeViewModel { .store(in: &disposeBag) // bind compose bar button item UI state - composeStatusAttribute.composeContent - .receive(on: DispatchQueue.main) - .map { content in - let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !content.isEmpty + let isComposeContentEmpty = composeStatusAttribute.composeContent + .map { ($0 ?? "").isEmpty } + let isComposeContentValid = Just(true).eraseToAnyPublisher() + let isMediaEmpty = attachmentServices + .map { $0.isEmpty } + let isMediaUploadAllSuccess = attachmentServices + .map { services in + services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } - .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) - .store(in: &disposeBag) + Publishers.CombineLatest4( + isComposeContentEmpty.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isMediaEmpty.eraseToAnyPublisher(), + isMediaUploadAllSuccess.eraseToAnyPublisher() + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .assign(to: \.value, on: isPublishBarButtonItemEnabled) + .store(in: &disposeBag) // bind modal dismiss state composeStatusAttribute.composeContent @@ -143,3 +158,11 @@ final class ComposeViewModel { } } + +// MARK: - MastodonAttachmentServiceDelegate +extension ComposeViewModel: MastodonAttachmentServiceDelegate { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { + // trigger new output event + attachmentServices.value = attachmentServices.value + } +} diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift new file mode 100644 index 000000000..b1c0fed75 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -0,0 +1,29 @@ +// +// APIService+Media.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import MastodonSDK + +extension APIService { + + func uploadMedia( + domain: String, + query: Mastodon.API.Media.UploadMeidaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.uploadMedia( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index dee775476..ece794320 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -7,10 +7,6 @@ import Foundation import Combine -import CoreData -import CoreDataStack -import CommonOSLog -import DateToolsSwift import MastodonSDK extension APIService { diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift deleted file mode 100644 index e29a04408..000000000 --- a/Mastodon/Service/MastodonAttachmentService.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// MastodonAttachmentService.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-17. -// - -import UIKit -import Combine -import PhotosUI - -final class MastodonAttachmentService { - - var disposeBag = Set() - - let identifier = UUID() - - // input - let pickerResult: PHPickerResult - let description = CurrentValueSubject(nil) - - // output - let imageData = CurrentValueSubject(nil) - let error = CurrentValueSubject(nil) - - init(pickerResult: PHPickerResult) { - self.pickerResult = pickerResult - // end init - - PHPickerResultLoader.loadImageData(from: pickerResult) - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - self.error.value = error - case .finished: - break - } - } receiveValue: { [weak self] imageData in - guard let self = self else { return } - self.imageData.value = imageData - } - .store(in: &disposeBag) - } - -} - -extension MastodonAttachmentService: Equatable, Hashable { - - static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { - return lhs.identifier == rhs.identifier - } - - func hash(into hasher: inout Hasher) { - hasher.combine(identifier) - } - -} diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift new file mode 100644 index 000000000..91f6f5ba1 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -0,0 +1,104 @@ +// +// MastodonAttachmentService+UploadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import GameplayKit +import Kingfisher +import MastodonSDK + +extension MastodonAttachmentService { + class UploadState: GKState { + weak var service: MastodonAttachmentService? + + init(service: MastodonAttachmentService) { + self.service = service + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + service?.uploadStateMachineSubject.send(self) + } + } +} + +extension MastodonAttachmentService.UploadState { + + class Initial: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard service?.authenticationBox != nil else { return false } + guard service?.imageData.value != nil else { return false } + return stateClass == Uploading.self + } + } + + class Uploading: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let service = service, let stateMachine = stateMachine else { return } + guard let authenticationBox = service.authenticationBox else { return } + guard let imageData = service.imageData.value else { return } + + let file: Mastodon.Query.MediaAttachment = { + if imageData.kf.imageFormat == .PNG { + return .png(imageData) + } else { + return .jpeg(imageData) + } + }() + let description = service.description.value + let query = Mastodon.API.Media.UploadMeidaQuery( + file: file, + thumbnail: nil, + description: description, + focus: nil + ) + + service.context.apiService.uploadMedia( + domain: authenticationBox.domain, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + service.error.send(error) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function) + + break + } + } receiveValue: { response in + service.attachment.value = response.value + stateMachine.enter(Finish.self) + } + .store(in: &service.disposeBag) + } + } + + class Fail: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Uploading.self || stateClass == Finish.self + } + } + + class Finish: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} + diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift new file mode 100644 index 000000000..cccb2bc4f --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -0,0 +1,107 @@ +// +// MastodonAttachmentService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import Combine +import PhotosUI +import Kingfisher +import GameplayKit +import MastodonSDK + +protocol MastodonAttachmentServiceDelegate: class { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) +} + +final class MastodonAttachmentService { + + var disposeBag = Set() + weak var delegate: MastodonAttachmentServiceDelegate? + + let identifier = UUID() + + // input + let context: AppContext + let pickerResult: PHPickerResult + var authenticationBox: AuthenticationService.MastodonAuthenticationBox? + + // output + // TODO: handle video/GIF/Audio data + let imageData = CurrentValueSubject(nil) + let attachment = CurrentValueSubject(nil) + let description = CurrentValueSubject(nil) + let error = CurrentValueSubject(nil) + + private(set) lazy var uploadStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + UploadState.Initial(service: self), + UploadState.Uploading(service: self), + UploadState.Fail(service: self), + UploadState.Finish(service: self), + ]) + stateMachine.enter(UploadState.Initial.self) + return stateMachine + }() + lazy var uploadStateMachineSubject = CurrentValueSubject(nil) + + init( + context: AppContext, + pickerResult: PHPickerResult, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.pickerResult = pickerResult + self.authenticationBox = initalAuthenticationBox + // end init + + uploadStateMachineSubject + .sink { [weak self] state in + guard let self = self else { return } + self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) + } + .store(in: &disposeBag) + + PHPickerResultLoader.loadImageData(from: pickerResult) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + self.imageData.value = imageData + + // Try pre-upload attachment for current active user + self.uploadStateMachine.enter(UploadState.Uploading.self) + } + .store(in: &disposeBag) + } + +} + +extension MastodonAttachmentService { + // FIXME: needs reset state for multiple account posting support + func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool { + authenticationBox = mastodonAuthenticationBox + return uploadStateMachine.enter(UploadState.self) + } +} + +extension MastodonAttachmentService: Equatable, Hashable { + + static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index 04273188b..c0ecd1aa3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -219,6 +219,7 @@ extension Mastodon.API.Account { data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) } } + data.append(Data.multipartEnd()) return data } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift new file mode 100644 index 000000000..e550d9069 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -0,0 +1,88 @@ +// +// Mastodon+API+Media.swift +// +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine + +extension Mastodon.API.Media { + + static func uploadMediaEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media") + } + + /// Upload media as attachment + /// + /// Creates an attachment to be used with a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func uploadMedia( + session: URLSession, + domain: String, + query: UploadMeidaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.post( + url: uploadMediaEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UploadMeidaQuery: PostQuery { + public let file: Mastodon.Query.MediaAttachment? + public let thumbnail: Mastodon.Query.MediaAttachment? + public let description: String? + public let focus: String? + + public init( + file: Mastodon.Query.MediaAttachment?, + thumbnail: Mastodon.Query.MediaAttachment?, + description: String?, + focus: String? + ) { + self.file = file + self.thumbnail = thumbnail + self.description = description + self.focus = focus + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + file.flatMap { data.append(Data.multipart(key: "file", value: $0)) } + thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) } + description.flatMap { data.append(Data.multipart(key: "description", value: $0)) } + focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) } + + data.append(Data.multipartEnd()) + return data + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index dfba19bf8..d96768878 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -94,6 +94,7 @@ extension Mastodon.API { public enum CustomEmojis { } public enum Favorites { } public enum Instance { } + public enum Media { } public enum OAuth { } public enum Onboarding { } public enum Polls { } From 36b42ba3e7f9904163e5294ff540871ac085c492 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 19 Mar 2021 19:49:48 +0800 Subject: [PATCH 133/400] feat: implement take photo and browser for image for compose scene --- Localization/app.json | 5 ++ .../Section/ComposeStatusSection.swift | 25 +++++-- Mastodon/Generated/Strings.swift | 8 ++ .../Button/normal.colorset/Contents.json | 18 +++++ .../Resources/en.lproj/Localizable.strings | 3 + .../Scene/Compose/ComposeViewController.swift | 72 +++++++++++++++++- .../ComposeViewModel+PublishState.swift | 73 +++++++++++++------ ...tachmentContainerView+EmptyStateView.swift | 1 + .../View/AttachmentContainerView.swift | 24 ++++-- .../Compose/View/ComposeToolbarView.swift | 71 ++++++++++++++---- .../Service/APIService/APIService+Media.swift | 17 +++++ .../MastodonAttachmentService.swift | 52 +++++++++++-- .../Mastodon+API+Account+Credentials.swift | 4 + .../MastodonSDK/API/Mastodon+API+Media.swift | 50 ++++++++++++- .../MastodonSDK/API/Mastodon+API.swift | 8 ++ .../Sources/MastodonSDK/Query/Query.swift | 9 ++- 16 files changed, 375 insertions(+), 65 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index a23e5f53e..9d61f5697 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -193,6 +193,11 @@ "new_post": "New Post", "new_reply": "New Reply" }, + "media_selection": { + "camera": "Take Photo", + "photo_library": "Photo Library", + "browse": "Browse" + }, "content_input_placeholder": "Type or paste what's on your mind", "compose_action": "Publish", "attachment": { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 772a327b2..b82b1f8de 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -85,19 +85,32 @@ extension ComposeStatusSection { cell.attachmentContainerView.previewImageView.image = placeholder return } - cell.attachmentContainerView.activityIndicatorView.stopAnimating() cell.attachmentContainerView.previewImageView.image = image .af.imageAspectScaled(toFill: cell.attachmentContainerView.previewImageView.frame.size) .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) } .store(in: &cell.disposeBag) - attachmentService.error - .receive(on: DispatchQueue.main) - .sink { error in + Publishers.CombineLatest( + attachmentService.uploadStateMachineSubject.eraseToAnyPublisher(), + attachmentService.error.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { uploadState, error in + cell.attachmentContainerView.emptyStateView.isHidden = error == nil + if let _ = error { cell.attachmentContainerView.activityIndicatorView.stopAnimating() - cell.attachmentContainerView.emptyStateView.isHidden = error == nil + } else { + guard let uploadState = uploadState else { return } + switch uploadState { + case is MastodonAttachmentService.UploadState.Finish, + is MastodonAttachmentService.UploadState.Fail: + cell.attachmentContainerView.activityIndicatorView.stopAnimating() + default: + break + } } - .store(in: &cell.disposeBag) + } + .store(in: &cell.disposeBag) NotificationCenter.default.publisher( for: UITextView.textDidChangeNotification, object: cell.attachmentContainerView.descriptionTextView diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 4bfb7bbf8..36432a6c5 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -158,6 +158,14 @@ internal enum L10n { /// video internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") } + internal enum MediaSelection { + /// Browse + internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse") + /// Take Photo + internal static let camera = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Camera") + /// Photo Library + internal static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary") + } internal enum Title { /// New Post internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json index d853a71aa..cd9b7c5ba 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -11,6 +11,24 @@ } }, "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x84", + "red" : "0x0A" + } + }, + "idiom" : "universal" } ], "info" : { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e751f6204..bfc4f0a49 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -46,6 +46,9 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.MediaSelection.Browse" = "Browse"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 254cd5835..0fbada7c4 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -62,7 +62,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { return backgroundView }() - lazy var imagePicker: PHPickerViewController = { + private(set) lazy var imagePicker: PHPickerViewController = { var configuration = PHPickerConfiguration() configuration.filter = .images configuration.selectionLimit = 4 @@ -71,6 +71,18 @@ final class ComposeViewController: UIViewController, NeedsDependency { imagePicker.delegate = self return imagePicker }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open) + documentPickerController.delegate = self + return documentPickerController + }() } @@ -433,9 +445,16 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - present(imagePicker, animated: true, completion: nil) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, mediaSelectionType.rawValue) + switch mediaSelectionType { + case .photoLibrary: + present(imagePicker, animated: true, completion: nil) + case .camera: + present(imagePickerController, animated: true, completion: nil) + case .browse: + present(documentPickerController, animated: true, completion: nil) + } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { @@ -500,6 +519,51 @@ extension ComposeViewController: PHPickerViewControllerDelegate { } } +// MARK: - UIImagePickerControllerDelegate +extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + + let attachmentService = MastodonAttachmentService( + context: context, + image: image, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + attachmentService.delegate = viewModel + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ComposeViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + let attachmentService = MastodonAttachmentService( + context: context, + imageData: imageData, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + attachmentService.delegate = viewModel + viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + // MARK: - ComposeStatusAttachmentTableViewCellDelegate extension ComposeViewController: ComposeStatusAttachmentTableViewCellDelegate { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 8b1cc0c97..2da46b655 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -48,31 +48,60 @@ extension ComposeViewModel.PublishState { return } - let mediaIDs = viewModel.attachmentServices.value.compactMap { attachmentService in + let domain = mastodonAuthenticationBox.domain + let attachmentServices = viewModel.attachmentServices.value + let mediaIDs = attachmentServices.compactMap { attachmentService in attachmentService.attachment.value?.id } - let query = Mastodon.API.Statuses.PublishStatusQuery( - status: viewModel.composeStatusAttribute.composeContent.value, - mediaIDs: mediaIDs - ) - publishingSubscription = viewModel.context.apiService.publishStatus( - domain: mastodonAuthenticationBox.domain, - query: query, - mastodonAuthenticationBox: mastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - stateMachine.enter(Fail.self) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) - stateMachine.enter(Finish.self) + let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { + var subscriptions: [AnyPublisher, Error>] = [] + for attachmentService in attachmentServices { + guard let attachmentID = attachmentService.attachment.value?.id else { continue } + let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !description.isEmpty else { continue } + let query = Mastodon.API.Media.UpdateMediaQuery( + file: nil, + thumbnail: nil, + description: description, + focus: nil + ) + let subscription = viewModel.context.apiService.updateMedia( + domain: domain, + attachmentID: attachmentID, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + subscriptions.append(subscription) + } + return subscriptions + }() + + publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) + .collect() + .flatMap { attachments -> AnyPublisher, Error> in + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: viewModel.composeStatusAttribute.composeContent.value, + mediaIDs: mediaIDs + ) + return viewModel.context.apiService.publishStatus( + domain: domain, + query: query, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Finish.self) + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) } - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) - } } } diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift index 8a0efa800..353fe7497 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -50,6 +50,7 @@ extension AttachmentContainerView.EmptyStateView { layer.masksToBounds = true layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius layer.cornerCurve = .continuous + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color let stackView = UIStackView() stackView.axis = .vertical diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index 5bf7020a3..cbad76830 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -14,7 +14,7 @@ final class AttachmentContainerView: UIView { var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? - let activityIndicatorView = UIActivityIndicatorView(style: .medium) + let activityIndicatorView = UIActivityIndicatorView(style: .large) let previewImageView: UIImageView = { let imageView = UIImageView() @@ -49,7 +49,8 @@ final class AttachmentContainerView: UIView { textView.textColor = .white textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto - textView.placeholderColor = Asset.Colors.Label.secondary.color + textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode + textView.returnKeyType = .done return textView }() @@ -115,12 +116,25 @@ extension AttachmentContainerView { activityIndicatorView.centerXAnchor.constraint(equalTo: previewImageView.centerXAnchor), activityIndicatorView.centerYAnchor.constraint(equalTo: previewImageView.centerYAnchor), ]) - - descriptionBackgroundView.overrideUserInterfaceStyle = .dark - + emptyStateView.isHidden = true activityIndicatorView.hidesWhenStopped = true activityIndicatorView.startAnimating() + + descriptionTextView.delegate = self } } + +// MARK: - UITextViewDelegate +extension AttachmentContainerView: UITextViewDelegate { + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + // let keyboard dismiss when input description with "done" type return key + if textView === descriptionTextView, text == "\n" { + textView.resignFirstResponder() + return false + } + + return true + } +} diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 7eb3ae821..dfbc70cb9 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -8,7 +8,7 @@ import UIKit protocol ComposeToolbarViewDelegate: class { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) @@ -17,41 +17,42 @@ protocol ComposeToolbarViewDelegate: class { final class ComposeToolbarView: UIView { + static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44) static let toolbarHeight: CGFloat = 44 weak var delegate: ComposeToolbarViewDelegate? let mediaButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) return button }() let pollButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton(type: .custom) + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "list.bullet", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) return button }() let emojiButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) return button }() let contentWarningButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) return button }() let visibilityButton: UIButton = { - let button = UIButton(type: .custom) - button.tintColor = Asset.Colors.Button.normal.color + let button = HighlightDimmableButton() + ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) return button }() @@ -99,7 +100,8 @@ extension ComposeToolbarView { ]) } - mediaButton.addTarget(self, action: #selector(ComposeToolbarView.cameraButtonDidPressed(_:)), for: .touchUpInside) + mediaButton.menu = createMediaContextMenu() + mediaButton.showsMenuAsPrimaryAction = true pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside) emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside) contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside) @@ -107,13 +109,52 @@ extension ComposeToolbarView { } } +extension ComposeToolbarView { + enum MediaSelectionType: String { + case camera + case photoLibrary + case browse + } +} extension ComposeToolbarView { - - @objc private func cameraButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, cameraButtonDidPressed: sender) + + private static func configureToolbarButtonAppearance(button: UIButton) { + button.tintColor = Asset.Colors.Button.normal.color + button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted) + button.layer.masksToBounds = true + button.layer.cornerRadius = 5 + button.layer.cornerCurve = .continuous } + private func createMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + +} + + +extension ComposeToolbarView { + @objc private func gifButtonDidPressed(_ sender: UIButton) { delegate?.composeToolbarView(self, gifButtonDidPressed: sender) } diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift index b1c0fed75..03e333424 100644 --- a/Mastodon/Service/APIService/APIService+Media.swift +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -26,4 +26,21 @@ extension APIService { ) } + func updateMedia( + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: Mastodon.API.Media.UpdateMediaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.updateMedia( + session: session, + domain: domain, + attachmentID: attachmentID, + query: query, + authorization: authorization + ) + } + } diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift index cccb2bc4f..79cf28e91 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -25,7 +25,6 @@ final class MastodonAttachmentService { // input let context: AppContext - let pickerResult: PHPickerResult var authenticationBox: AuthenticationService.MastodonAuthenticationBox? // output @@ -54,16 +53,10 @@ final class MastodonAttachmentService { initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? ) { self.context = context - self.pickerResult = pickerResult self.authenticationBox = initalAuthenticationBox // end init - uploadStateMachineSubject - .sink { [weak self] state in - guard let self = self else { return } - self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) - } - .store(in: &disposeBag) + setupServiceObserver() PHPickerResultLoader.loadImageData(from: pickerResult) .sink { [weak self] completion in @@ -84,6 +77,49 @@ final class MastodonAttachmentService { .store(in: &disposeBag) } + init( + context: AppContext, + image: UIImage, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + imageData.value = image.jpegData(compressionQuality: 0.75) + + // Try pre-upload attachment for current active user + uploadStateMachine.enter(UploadState.Uploading.self) + } + + init( + context: AppContext, + imageData: Data, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.authenticationBox = initalAuthenticationBox + // end init + + setupServiceObserver() + + self.imageData.value = imageData + + // Try pre-upload attachment for current active user + uploadStateMachine.enter(UploadState.Uploading.self) + } + + private func setupServiceObserver() { + uploadStateMachineSubject + .sink { [weak self] state in + guard let self = self else { return } + self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) + } + .store(in: &disposeBag) + } + } extension MastodonAttachmentService { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index c0ecd1aa3..6f324627b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -198,6 +198,10 @@ extension Mastodon.API.Account { return Self.multipartContentType() } + var queryItems: [URLQueryItem]? { + return nil + } + var body: Data? { var data = Data() diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift index e550d9069..5ae344b3d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -50,7 +50,7 @@ extension Mastodon.API.Media { .eraseToAnyPublisher() } - public struct UploadMeidaQuery: PostQuery { + public struct UploadMeidaQuery: PostQuery, PutQuery { public let file: Mastodon.Query.MediaAttachment? public let thumbnail: Mastodon.Query.MediaAttachment? public let description: String? @@ -86,3 +86,51 @@ extension Mastodon.API.Media { } } + +extension Mastodon.API.Media { + + static func updateMediaEndpointURL(domain: String, attachmentID: Mastodon.Entity.Attachment.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media").appendingPathComponent(attachmentID) + } + + /// Update attachment + /// + /// Update an Attachment, before it is attached to a status and posted.. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func updateMedia( + session: URLSession, + domain: String, + attachmentID: Mastodon.Entity.Attachment.ID, + query: UpdateMediaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.put( + url: updateMediaEndpointURL(domain: domain, attachmentID: attachmentID), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public typealias UpdateMediaQuery = UploadMeidaQuery + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index d96768878..225c18696 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -128,6 +128,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PATCH, query: query, authorization: authorization) } + + static func put( + url: URL, + query: PutQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index a0a5e4eae..39f6e3ec4 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -35,6 +35,7 @@ extension RequestQuery where Self: Encodable { } } +// GET protocol GetQuery: RequestQuery { } extension GetQuery { @@ -43,6 +44,7 @@ extension GetQuery { var contentType: String? { nil } } +// POST protocol PostQuery: RequestQuery { } extension PostQuery { @@ -50,10 +52,9 @@ extension PostQuery { var queryItems: [URLQueryItem]? { nil } } +// PATCH protocol PatchQuery: RequestQuery { } -extension PatchQuery { - // By default a `PatchQuery` does not has query items - var queryItems: [URLQueryItem]? { nil } -} +// PUT +protocol PutQuery: RequestQuery { } From b296b21ef08ed35bb37e8159e5f13b2e02327290 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 22 Mar 2021 17:48:35 +0800 Subject: [PATCH 134/400] feat: add image attachments reorder support for status compose scene --- Mastodon.xcodeproj/project.pbxproj | 22 ++-- .../Section/ComposeStatusSection.swift | 29 ++--- ...pliedToTootContentCollectionViewCell.swift | 31 +++++ ...ComposeStatusAttachmentTableViewCell.swift | 37 +++--- ...poseStatusContentCollectionViewCell.swift} | 19 ++- .../Scene/Compose/ComposeViewController.swift | 116 ++++++++++++------ .../Compose/ComposeViewModel+Diffable.swift | 28 ++++- Mastodon/Scene/Compose/ComposeViewModel.swift | 3 +- ...oseRepliedToTootContentTableViewCell.swift | 31 ----- ...astodonAttachmentService+UploadState.swift | 2 +- 10 files changed, 190 insertions(+), 128 deletions(-) create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift rename Mastodon/Scene/Compose/{TableViewCell => CollectionViewCell}/ComposeStatusAttachmentTableViewCell.swift (67%) rename Mastodon/Scene/Compose/{TableViewCell/ComposeStatusContentTableViewCell.swift => CollectionViewCell/ComposeStatusContentCollectionViewCell.swift} (86%) delete mode 100644 Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 684141b1b..d6e489b2a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -179,8 +179,8 @@ DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */; }; - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */; }; + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; @@ -468,8 +468,8 @@ DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentTableViewCell.swift; sourceTree = ""; }; - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentTableViewCell.swift; sourceTree = ""; }; + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; @@ -1122,7 +1122,7 @@ isa = PBXGroup; children = ( DB55D32225FB4D320002F825 /* View */, - DB789A2125F9F76D0071ACA0 /* TableViewCell */, + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, @@ -1131,14 +1131,14 @@ path = Compose; sourceTree = ""; }; - DB789A2125F9F76D0071ACA0 /* TableViewCell */ = { + DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift */, - DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift */, + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, + DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, ); - path = TableViewCell; + path = CollectionViewCell; sourceTree = ""; }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { @@ -1842,7 +1842,7 @@ DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, - DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentTableViewCell.swift in Sources */, + DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, @@ -1922,7 +1922,7 @@ 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentTableViewCell.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index b82b1f8de..33ef0f268 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -26,22 +26,22 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func tableViewDiffableDataSource( - for tableView: UITableView, + + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentTableViewCellDelegate - ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak textEditorViewTextAttributesDelegate, weak composeStatusAttachmentTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self), for: indexPath) as! ComposeRepliedToTootContentTableViewCell - // TODO: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell return cell case .input(let replyToTootObjectID, let attribute): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusContentTableViewCell.self), for: indexPath) as! ComposeStatusContentTableViewCell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToTootObjectID = replyToTootObjectID, @@ -59,24 +59,24 @@ extension ComposeStatusSection { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in - tableView.beginUpdates() - tableView.endUpdates() + collectionView.collectionViewLayout.invalidateLayout() // bind input data attribute.composeContent.value = text } .store(in: &cell.disposeBag) return cell case .attachment(let attachmentService): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self), for: indexPath) as! ComposeStatusAttachmentTableViewCell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell cell.attachmentContainerView.descriptionTextView.text = attachmentService.description.value cell.delegate = composeStatusAttachmentTableViewCellDelegate attachmentService.imageData .receive(on: DispatchQueue.main) .sink { imageData in + let size = cell.attachmentContainerView.previewImageView.frame.size != .zero ? cell.attachmentContainerView.previewImageView.frame.size : CGSize(width: 1, height: 1) guard let imageData = imageData, let image = UIImage(data: imageData) else { let placeholder = UIImage.placeholder( - size: cell.attachmentContainerView.previewImageView.frame.size, + size: size, color: Asset.Colors.Background.systemGroupedBackground.color ) .af.imageRounded( @@ -86,7 +86,7 @@ extension ComposeStatusSection { return } cell.attachmentContainerView.previewImageView.image = image - .af.imageAspectScaled(toFill: cell.attachmentContainerView.previewImageView.frame.size) + .af.imageAspectScaled(toFill: size) .af.imageRounded(withCornerRadius: AttachmentContainerView.containerViewCornerRadius) } .store(in: &cell.disposeBag) @@ -97,6 +97,7 @@ extension ComposeStatusSection { .receive(on: DispatchQueue.main) .sink { uploadState, error in cell.attachmentContainerView.emptyStateView.isHidden = error == nil + cell.attachmentContainerView.descriptionBackgroundView.isHidden = error != nil if let _ = error { cell.attachmentContainerView.activityIndicatorView.stopAnimating() } else { @@ -130,7 +131,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { static func configure( - cell: ComposeStatusContentTableViewCell, + cell: ComposeStatusContentCollectionViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { // set avatar diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift new file mode 100644 index 000000000..fe00563df --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift @@ -0,0 +1,31 @@ +// +// ComposeRepliedToTootContentCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-11. +// + +import UIKit + +final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeRepliedToTootContentCollectionViewCell { + + private func _init() { + + } + +} + diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift similarity index 67% rename from Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift index 88ae255fc..bc087c990 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift @@ -9,18 +9,18 @@ import os.log import UIKit import Combine -protocol ComposeStatusAttachmentTableViewCellDelegate: class { - func composeStatusAttachmentTableViewCell(_ cell: ComposeStatusAttachmentTableViewCell, removeButtonDidPressed button: UIButton) +protocol ComposeStatusAttachmentCollectionViewCellDelegate: class { + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) } -final class ComposeStatusAttachmentTableViewCell: UITableViewCell { +final class ComposeStatusAttachmentCollectionViewCell: UICollectionViewCell { var disposeBag = Set() - static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentTableViewCell.removeButtonSize.height * 0.5 + static let verticalMarginHeight: CGFloat = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height * 0.5 static let removeButtonSize = CGSize(width: 22, height: 22) - weak var delegate: ComposeStatusAttachmentTableViewCellDelegate? + weak var delegate: ComposeStatusAttachmentCollectionViewCellDelegate? let attachmentContainerView = AttachmentContainerView() let removeButton: UIButton = { @@ -31,7 +31,7 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { button.setImage(image, for: .normal) button.setBackgroundImage(.placeholder(color: Asset.Colors.Background.danger.color), for: .normal) button.layer.masksToBounds = true - button.layer.cornerRadius = ComposeStatusAttachmentTableViewCell.removeButtonSize.width * 0.5 + button.layer.cornerRadius = ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width * 0.5 button.layer.borderColor = Asset.Colors.Background.dangerBorder.color.cgColor button.layer.borderWidth = 1 return button @@ -41,11 +41,14 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { super.prepareForReuse() attachmentContainerView.activityIndicatorView.startAnimating() + attachmentContainerView.previewImageView.af.cancelImageRequest() + attachmentContainerView.previewImageView.image = .placeholder(color: .systemFill) delegate = nil + disposeBag.removeAll() } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) _init() } @@ -56,18 +59,18 @@ final class ComposeStatusAttachmentTableViewCell: UITableViewCell { } -extension ComposeStatusAttachmentTableViewCell { +extension ComposeStatusAttachmentCollectionViewCell { private func _init() { - selectionStyle = .none + // selectionStyle = .none attachmentContainerView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(attachmentContainerView) NSLayoutConstraint.activate([ - attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentTableViewCell.verticalMarginHeight), + attachmentContainerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), attachmentContainerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), attachmentContainerView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentTableViewCell.verticalMarginHeight), + contentView.bottomAnchor.constraint(equalTo: attachmentContainerView.bottomAnchor, constant: ComposeStatusAttachmentCollectionViewCell.verticalMarginHeight), attachmentContainerView.heightAnchor.constraint(equalToConstant: 205).priority(.defaultHigh), ]) @@ -76,21 +79,21 @@ extension ComposeStatusAttachmentTableViewCell { NSLayoutConstraint.activate([ removeButton.centerXAnchor.constraint(equalTo: attachmentContainerView.trailingAnchor), removeButton.centerYAnchor.constraint(equalTo: attachmentContainerView.topAnchor), - removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentTableViewCell.removeButtonSize.width).priority(.defaultHigh), - removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentTableViewCell.removeButtonSize.height).priority(.defaultHigh), + removeButton.widthAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.width).priority(.defaultHigh), + removeButton.heightAnchor.constraint(equalToConstant: ComposeStatusAttachmentCollectionViewCell.removeButtonSize.height).priority(.defaultHigh), ]) - removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentTableViewCell.removeButtonDidPressed(_:)), for: .touchUpInside) + removeButton.addTarget(self, action: #selector(ComposeStatusAttachmentCollectionViewCell.removeButtonDidPressed(_:)), for: .touchUpInside) } } -extension ComposeStatusAttachmentTableViewCell { +extension ComposeStatusAttachmentCollectionViewCell { @objc private func removeButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.composeStatusAttachmentTableViewCell(self, removeButtonDidPressed: sender) + delegate?.composeStatusAttachmentCollectionViewCell(self, removeButtonDidPressed: sender) } } diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift similarity index 86% rename from Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index f5f778946..80e8cf875 100644 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeStatusContentTableViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeStatusContentTableViewCell.swift +// ComposeStatusContentCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. @@ -9,7 +9,7 @@ import UIKit import Combine import TwitterTextEditor -final class ComposeStatusContentTableViewCell: UITableViewCell { +final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { var disposeBag = Set() @@ -27,8 +27,8 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { let composeContent = PassthroughSubject() - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) + override init(frame: CGRect) { + super.init(frame: frame) _init() } @@ -39,10 +39,11 @@ final class ComposeStatusContentTableViewCell: UITableViewCell { } -extension ComposeStatusContentTableViewCell { +extension ComposeStatusContentCollectionViewCell { private func _init() { - selectionStyle = .none + // selectionStyle = .none + preservesSuperviewLayoutMargins = true statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) @@ -82,12 +83,8 @@ extension ComposeStatusContentTableViewCell { } -extension ComposeStatusContentTableViewCell { - -} - // MARK: - UITextViewDelegate -extension ComposeStatusContentTableViewCell: TextEditorViewChangeObserver { +extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 0fbada7c4..5c7615ea0 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -39,15 +39,14 @@ final class ComposeViewController: UIViewController, NeedsDependency { return barButtonItem }() - let tableView: UITableView = { - let tableView = ControlContainableTableView() - tableView.register(ComposeRepliedToTootContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToTootContentTableViewCell.self)) - tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) - tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) - tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .none - tableView.showsVerticalScrollIndicator = false - return tableView + let collectionView: UICollectionView = { + let collectionViewLayout = ComposeViewController.createLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) + collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) + collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) + collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color + return collectionView }() let composeToolbarView: ComposeToolbarView = { @@ -86,6 +85,20 @@ final class ComposeViewController: UIViewController, NeedsDependency { } +extension ComposeViewController { + private static func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsetsReference = .readableContent + // section.interGroupSpacing = 10 + // section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + return UICollectionViewCompositionalLayout(section: section) + } +} + extension ComposeViewController { override func viewDidLoad() { @@ -103,13 +116,13 @@ extension ComposeViewController { navigationItem.rightBarButtonItem = publishBarButtonItem publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) - tableView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tableView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) composeToolbarView.translatesAutoresizingMaskIntoConstraints = false @@ -133,9 +146,11 @@ extension ComposeViewController { view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), ]) - tableView.delegate = self + collectionView.delegate = self + let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) + collectionView.addGestureRecognizer(longPressReorderGesture) viewModel.setupDiffableDataSource( - for: tableView, + for: collectionView, dependency: self, textEditorViewTextAttributesDelegate: self, composeStatusAttachmentTableViewCellDelegate: self @@ -151,45 +166,45 @@ extension ComposeViewController { ) .sink(receiveValue: { [weak self] isShow, state, endFrame in guard let self = self else { return } - + guard isShow, state == .dock else { - self.tableView.contentInset.bottom = 0.0 - self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } return } // isShow AND dock state - let contentFrame = self.view.convert(self.tableView.frame, to: nil) + let contentFrame = self.view.convert(self.collectionView.frame, to: nil) let padding = contentFrame.maxY - endFrame.minY guard padding > 0 else { - self.tableView.contentInset.bottom = 0.0 - self.tableView.verticalScrollIndicatorInsets.bottom = 0.0 + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { - self.composeToolbarViewBottomLayoutConstraint.constant = 0.0 + self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } return } // add 16pt margin - self.tableView.contentInset.bottom = padding + 16 - self.tableView.verticalScrollIndicatorInsets.bottom = padding + 16 + self.collectionView.contentInset.bottom = padding + 16 + self.collectionView.verticalScrollIndicatorInsets.bottom = padding + 16 UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = padding self.view.layoutIfNeeded() } }) .store(in: &disposeBag) - + viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) - + // bind custom emojis viewModel.customEmojiViewModel .compactMap { $0?.emojis } @@ -203,7 +218,7 @@ extension ComposeViewController { self.textEditorView()?.setNeedsUpdateTextAttributes() }) .store(in: &disposeBag) - + // bind image picker toolbar state viewModel.attachmentServices .receive(on: DispatchQueue.main) @@ -236,7 +251,7 @@ extension ComposeViewController { switch item { case .input: guard let indexPath = diffableDataSource.indexPath(for: item), - let cell = tableView.cellForRow(at: indexPath) as? ComposeStatusContentTableViewCell else { + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { continue } return cell.textEditorView @@ -306,6 +321,33 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } + + @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { + switch(sender.state) { + case .began: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)) else { + break + } + collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) + case .changed: + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let diffableDataSource = viewModel.diffableDataSource else { + break + } + guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), + case .attachment = item else { + collectionView.cancelInteractiveMovement() + return + } + + collectionView.updateInteractiveMovementTargetPosition(sender.location(in: collectionView)) + case .ended: + collectionView.endInteractiveMovement() + default: + collectionView.cancelInteractiveMovement() + } + } + } // MARK: - TextEditorViewTextAttributesDelegate @@ -476,10 +518,8 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } // MARK: - UITableViewDelegate -extension ComposeViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } +extension ComposeViewController: UICollectionViewDelegate { + } // MARK: - UIAdaptivePresentationControllerDelegate @@ -565,11 +605,11 @@ extension ComposeViewController: UIDocumentPickerDelegate { } // MARK: - ComposeStatusAttachmentTableViewCellDelegate -extension ComposeViewController: ComposeStatusAttachmentTableViewCellDelegate { +extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { - func composeStatusAttachmentTableViewCell(_ cell: ComposeStatusAttachmentTableViewCell, removeButtonDidPressed button: UIButton) { + func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case let .attachment(attachmentService) = item else { return } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index d989d6f46..17465cf01 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -11,13 +11,13 @@ import TwitterTextEditor extension ComposeViewModel { func setupDiffableDataSource( - for tableView: UITableView, + for collectionView: UICollectionView, dependency: NeedsDependency, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentTableViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate ) { - diffableDataSource = ComposeStatusSection.tableViewDiffableDataSource( - for: tableView, + let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( + for: collectionView, dependency: dependency, managedObjectContext: context.managedObjectContext, composeKind: composeKind, @@ -25,6 +25,26 @@ extension ComposeViewModel { composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate ) + diffableDataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .attachment: return true + default: return false + } + + } + diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var attachmentServices: [MastodonAttachmentService] = [] + for item in items { + guard case let .attachment(attachmentService) = item else { continue } + attachmentServices.append(attachmentService) + } + self.attachmentServices.value = attachmentServices + } + self.diffableDataSource = diffableDataSource + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status, .attachment]) switch composeKind { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 2d6dc728d..b81306eac 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -23,7 +23,8 @@ final class ComposeViewModel { let activeAuthenticationBox: CurrentValueSubject // output - var diffableDataSource: UITableViewDiffableDataSource! + //var diffableDataSource: UITableViewDiffableDataSource! + var diffableDataSource: UICollectionViewDiffableDataSource! private(set) lazy var publishStateMachine: GKStateMachine = { // exclude timeline middle fetcher state let stateMachine = GKStateMachine(states: [ diff --git a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift b/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift deleted file mode 100644 index def777caf..000000000 --- a/Mastodon/Scene/Compose/TableViewCell/ComposeRepliedToTootContentTableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ComposeRepliedToTootContentTableViewCell.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import UIKit - -final class ComposeRepliedToTootContentTableViewCell: UITableViewCell { - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ComposeRepliedToTootContentTableViewCell { - - private func _init() { - - } - -} - diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift index 91f6f5ba1..8493d82a0 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -76,10 +76,10 @@ extension MastodonAttachmentService.UploadState { service.error.send(error) case .finished: os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function) - break } } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment %s success: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.url) service.attachment.value = response.value stateMachine.enter(Finish.self) } From c35fcfb08f33cefaa79599a901dcfe3d2ed762fe Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 22 Mar 2021 18:40:32 +0800 Subject: [PATCH 135/400] feat: make image attachments uploading in the queue --- .../Scene/Compose/ComposeViewController.swift | 8 ++-- .../Compose/ComposeViewModel+Diffable.swift | 40 ++++++++++--------- Mastodon/Scene/Compose/ComposeViewModel.swift | 22 +++++++++- ...astodonAttachmentService+UploadState.swift | 11 ++++- .../MastodonAttachmentService.swift | 13 ++---- 5 files changed, 60 insertions(+), 34 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 5c7615ea0..760496026 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -147,8 +147,9 @@ extension ComposeViewController { ]) collectionView.delegate = self - let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) - collectionView.addGestureRecognizer(longPressReorderGesture) + // Note: do not allow reorder due to the images display order following the upload time + // let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) + // collectionView.addGestureRecognizer(longPressReorderGesture) viewModel.setupDiffableDataSource( for: collectionView, dependency: self, @@ -321,7 +322,7 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } - + /* Do not allow reorder image due to image display order following the update time @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { switch(sender.state) { case .began: @@ -347,6 +348,7 @@ extension ComposeViewController { collectionView.cancelInteractiveMovement() } } + */ } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 17465cf01..389d23edb 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -24,27 +24,29 @@ extension ComposeViewModel { textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate ) + + // Note: do not allow reorder due to the images display order following the upload time + // diffableDataSource.reorderingHandlers.canReorderItem = { item in + // switch item { + // case .attachment: return true + // default: return false + // } + // + // } + // diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + // guard let self = self else { return } + // + // let items = transaction.finalSnapshot.itemIdentifiers + // var attachmentServices: [MastodonAttachmentService] = [] + // for item in items { + // guard case let .attachment(attachmentService) = item else { continue } + // attachmentServices.append(attachmentService) + // } + // self.attachmentServices.value = attachmentServices + // } + // - diffableDataSource.reorderingHandlers.canReorderItem = { item in - switch item { - case .attachment: return true - default: return false - } - - } - diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in - guard let self = self else { return } - - let items = transaction.finalSnapshot.itemIdentifiers - var attachmentServices: [MastodonAttachmentService] = [] - for item in items { - guard case let .attachment(attachmentService) = item else { continue } - attachmentServices.append(attachmentService) - } - self.attachmentServices.value = attachmentServices - } self.diffableDataSource = diffableDataSource - var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.repliedTo, .status, .attachment]) switch composeKind { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index b81306eac..ea998b778 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -137,7 +137,7 @@ final class ComposeViewModel { } .store(in: &disposeBag) - // bind snapshot + // bind snapshot and drive service upload state attachmentServices .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices in @@ -154,6 +154,26 @@ final class ComposeViewModel { snapshot.appendItems(items, toSection: .attachment) diffableDataSource.apply(snapshot) + + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } + } } .store(in: &disposeBag) } diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift index 8493d82a0..9fd4b1298 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -31,8 +31,15 @@ extension MastodonAttachmentService.UploadState { class Initial: MastodonAttachmentService.UploadState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard service?.authenticationBox != nil else { return false } - guard service?.imageData.value != nil else { return false } - return stateClass == Uploading.self + if stateClass == Initial.self { + return true + } + + if service?.imageData.value != nil { + return stateClass == Uploading.self + } else { + return stateClass == Fail.self + } } } diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift index 79cf28e91..3a57a9d98 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -64,15 +64,14 @@ final class MastodonAttachmentService { switch completion { case .failure(let error): self.error.value = error + self.uploadStateMachine.enter(UploadState.Fail.self) case .finished: break } } receiveValue: { [weak self] imageData in guard let self = self else { return } self.imageData.value = imageData - - // Try pre-upload attachment for current active user - self.uploadStateMachine.enter(UploadState.Uploading.self) + self.uploadStateMachine.enter(UploadState.Initial.self) } .store(in: &disposeBag) } @@ -89,9 +88,7 @@ final class MastodonAttachmentService { setupServiceObserver() imageData.value = image.jpegData(compressionQuality: 0.75) - - // Try pre-upload attachment for current active user - uploadStateMachine.enter(UploadState.Uploading.self) + uploadStateMachine.enter(UploadState.Initial.self) } init( @@ -106,9 +103,7 @@ final class MastodonAttachmentService { setupServiceObserver() self.imageData.value = imageData - - // Try pre-upload attachment for current active user - uploadStateMachine.enter(UploadState.Uploading.self) + uploadStateMachine.enter(UploadState.Initial.self) } private func setupServiceObserver() { From b8e062c92e75e0cc8df6b77ab0e8e400688690f9 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 23 Mar 2021 18:47:21 +0800 Subject: [PATCH 136/400] feat: add poll UI/UX for compose scene --- Mastodon.xcodeproj/project.pbxproj | 25 ++- .../Diffiable/Item/CategoryPickerItem.swift | 1 + .../Diffiable/Item/ComposeStatusItem.swift | 22 ++ Mastodon/Diffiable/Item/PollItem.swift | 1 + .../Section/ComposeStatusSection.swift | 20 +- Mastodon/Diffiable/Section/PollSection.swift | 42 ++-- Mastodon/Generated/Assets.swift | 2 + .../plus.circle.imageset/Contents.json | 15 ++ .../plus.circle.imageset/plus.circle.pdf | 95 +++++++++ .../system.background.colorset/Contents.json | 12 +- .../Contents.json | 12 +- .../Contents.json | 24 ++- .../Contents.json | 38 ++++ ...mposeStatusContentCollectionViewCell.swift | 2 +- ...tatusNewPollOptionCollectionViewCell.swift | 120 +++++++++++ ...seStatusPollOptionCollectionViewCell.swift | 147 +++++++++++++ .../Scene/Compose/ComposeViewController.swift | 180 +++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 10 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 120 +++++++---- .../Compose/View/ComposeToolbarView.swift | 40 ++-- .../Share/View/Content/PollOptionView.swift | 200 ++++++++++++++++++ .../PollOptionTableViewCell.swift | 176 +++------------ .../DeleteBackwardResponseTextField.swift | 24 +++ 23 files changed, 1077 insertions(+), 251 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift create mode 100644 Mastodon/Scene/Share/View/Content/PollOptionView.swift create mode 100644 Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0a3c58b9c..50f779c64 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -187,6 +187,10 @@ DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; + DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; + DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; + DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */; }; + DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; DB89BA0025C10FD0008580ED /* CoreDataStack.h in Headers */ = {isa = PBXBuildFile; fileRef = DB89B9F025C10FD0008580ED /* CoreDataStack.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -481,6 +485,10 @@ DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; + DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; + DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; + DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusNewPollOptionCollectionViewCell.swift; sourceTree = ""; }; + DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; DB89B9F125C10FD0008580ED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -665,6 +673,7 @@ 2D152A8B25C295CC009AA50C /* StatusView.swift */, 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, + DB87D44A2609C11900D12C0D /* PollOptionView.swift */, ); path = Content; sourceTree = ""; @@ -752,7 +761,6 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, - DB49A61925FF327D00B98345 /* EmojiService */, DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, @@ -843,6 +851,7 @@ DB9D6C1325E4F97A0051B173 /* Container */, DBA9B90325F1D4420012E7B6 /* Control */, 2D152A8A25C295B8009AA50C /* Content */, + DB87D45C2609DE6600D12C0D /* TextField */, DB1D187125EF5BBD003F1F23 /* TableView */, 2D7631A625C1533800929FB9 /* TableviewCell */, ); @@ -1153,10 +1162,20 @@ DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, + DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, + DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; }; + DB87D45C2609DE6600D12C0D /* TextField */ = { + isa = PBXGroup; + children = ( + DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */, + ); + path = TextField; + sourceTree = ""; + }; DB89B9EF25C10FD0008580ED /* CoreDataStack */ = { isa = PBXGroup; children = ( @@ -1792,6 +1811,7 @@ DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, + DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, @@ -1818,6 +1838,7 @@ DB98338825C945ED00AD9700 /* Assets.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, + DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, @@ -1868,6 +1889,7 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -1931,6 +1953,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift index 9a8f8bd6c..52bdaf39e 100644 --- a/Mastodon/Diffiable/Item/CategoryPickerItem.swift +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -8,6 +8,7 @@ import Foundation import MastodonSDK +/// Note: update Equatable when change case enum CategoryPickerItem { case all case category(category: Mastodon.Entity.Category) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index d49203c33..5d6e12fa2 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -9,12 +9,17 @@ import Foundation import Combine import CoreData +/// Note: update Equatable when change case enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) case attachment(attachmentService: MastodonAttachmentService) + case poll(attribute: ComposePollAttribute) + case newPoll } +extension ComposeStatusItem: Equatable { } + extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { @@ -38,3 +43,20 @@ extension ComposeStatusItem { } } } + +extension ComposeStatusItem { + final class ComposePollAttribute: Equatable, Hashable { + private let id = UUID() + + let option = CurrentValueSubject("") + + static func == (lhs: ComposePollAttribute, rhs: ComposePollAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.option.value == rhs.option.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Item/PollItem.swift b/Mastodon/Diffiable/Item/PollItem.swift index 006400f9e..1e7bd4ce7 100644 --- a/Mastodon/Diffiable/Item/PollItem.swift +++ b/Mastodon/Diffiable/Item/PollItem.swift @@ -8,6 +8,7 @@ import Foundation import CoreData +/// Note: update Equatable when change case enum PollItem { case opion(objectID: NSManagedObjectID, attribute: Attribute) } diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 33ef0f268..e8fee6d47 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -16,6 +16,7 @@ enum ComposeStatusSection: Equatable, Hashable { case repliedTo case status case attachment + case poll } extension ComposeStatusSection { @@ -33,7 +34,9 @@ extension ComposeStatusSection { managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in switch item { @@ -54,11 +57,11 @@ extension ComposeStatusSection { } ComposeStatusSection.configure(cell: cell, attribute: attribute) cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate - // self size input cell cell.composeContent .removeDuplicates() .receive(on: DispatchQueue.main) .sink { text in + // self size input cell collectionView.collectionViewLayout.invalidateLayout() // bind input data attribute.composeContent.value = text @@ -124,6 +127,19 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) return cell + case .poll(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell + cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOption + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: attribute.option) + .store(in: &cell.disposeBag) + cell.delegate = composeStatusPollOptionCollectionViewCellDelegate + return cell + case .newPoll: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusNewPollOptionCollectionViewCell + cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate + return cell } } } diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 45da63bde..2f9404410 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -38,7 +38,7 @@ extension PollSection { pollOption option: PollOption, pollItemAttribute attribute: PollItem.Attribute ) { - cell.optionLabel.text = option.title + cell.pollOptionView.optionTextField.text = option.title configure(cell: cell, selectState: attribute.selectState) configure(cell: cell, voteState: attribute.voteState) cell.attribute = attribute @@ -52,35 +52,35 @@ extension PollSection { static func configure(cell: PollOptionTableViewCell, selectState state: PollItem.Attribute.SelectState) { switch state { case .none: - cell.checkmarkBackgroundView.isHidden = true - cell.checkmarkImageView.isHidden = true + cell.pollOptionView.checkmarkBackgroundView.isHidden = true + cell.pollOptionView.checkmarkImageView.isHidden = true case .off: - cell.checkmarkBackgroundView.backgroundColor = .systemBackground - cell.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor - cell.checkmarkBackgroundView.layer.borderWidth = 1 - cell.checkmarkBackgroundView.isHidden = false - cell.checkmarkImageView.isHidden = true + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + cell.pollOptionView.checkmarkBackgroundView.isHidden = false + cell.pollOptionView.checkmarkImageView.isHidden = true case .on: - cell.checkmarkBackgroundView.backgroundColor = .systemBackground - cell.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor - cell.checkmarkBackgroundView.layer.borderWidth = 0 - cell.checkmarkBackgroundView.isHidden = false - cell.checkmarkImageView.isHidden = false + cell.pollOptionView.checkmarkBackgroundView.backgroundColor = .systemBackground + cell.pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.clear.cgColor + cell.pollOptionView.checkmarkBackgroundView.layer.borderWidth = 0 + cell.pollOptionView.checkmarkBackgroundView.isHidden = false + cell.pollOptionView.checkmarkImageView.isHidden = false } } static func configure(cell: PollOptionTableViewCell, voteState state: PollItem.Attribute.VoteState) { switch state { case .hidden: - cell.optionPercentageLabel.isHidden = true - cell.voteProgressStripView.isHidden = true - cell.voteProgressStripView.setProgress(0.0, animated: false) + cell.pollOptionView.optionPercentageLabel.isHidden = true + cell.pollOptionView.voteProgressStripView.isHidden = true + cell.pollOptionView.voteProgressStripView.setProgress(0.0, animated: false) case .reveal(let voted, let percentage, let animated): - cell.optionPercentageLabel.isHidden = false - cell.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" - cell.voteProgressStripView.isHidden = false - cell.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color - cell.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) + cell.pollOptionView.optionPercentageLabel.isHidden = false + cell.pollOptionView.optionPercentageLabel.text = String(Int(100 * percentage)) + "%" + cell.pollOptionView.voteProgressStripView.isHidden = false + cell.pollOptionView.voteProgressStripView.tintColor = voted ? Asset.Colors.Background.Poll.highlight.color : Asset.Colors.Background.Poll.disabled.color + cell.pollOptionView.voteProgressStripView.setProgress(CGFloat(percentage), animated: animated) } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ba58cb3f1..366c70649 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -27,6 +27,7 @@ internal enum Asset { } internal enum Circles { internal static let plusCircleFill = ImageAsset(name: "Circles/plus.circle.fill") + internal static let plusCircle = ImageAsset(name: "Circles/plus.circle") } internal enum Colors { internal enum Background { @@ -46,6 +47,7 @@ internal enum Asset { internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") + internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") } internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json new file mode 100644 index 000000000..30eea7b43 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plus.circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf new file mode 100644 index 000000000..65d55fe27 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Circles/plus.circle.imageset/plus.circle.pdf @@ -0,0 +1,95 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.000000 0.000000 0.000000 scn +21.999905 0.000160 m +34.035152 0.000160 44.000000 9.986404 44.000000 22.000080 c +44.000000 34.035332 34.013599 44.000000 21.978350 44.000000 c +9.964656 44.000000 0.000000 34.035332 0.000000 22.000080 c +0.000000 9.986404 9.986255 0.000160 21.999905 0.000160 c +h +21.999905 3.666824 m +11.819542 3.666824 3.688203 11.819717 3.688203 22.000080 c +3.688203 32.180443 11.797986 40.333336 21.978350 40.333336 c +32.158710 40.333336 40.311611 32.180443 40.333256 22.000080 c +40.354897 11.819717 32.180267 3.666824 21.999905 3.666824 c +h +13.782296 20.188307 m +20.166574 20.188307 l +20.166574 13.760918 l +20.166574 12.682493 20.899923 11.949142 21.956793 11.949142 c +23.035217 11.949142 23.790121 12.682493 23.790121 13.760918 c +23.790121 20.188307 l +30.217514 20.188307 l +31.295938 20.188307 32.029289 20.921658 32.029289 21.978525 c +32.029289 23.056950 31.295938 23.811855 30.217514 23.811855 c +23.790121 23.811855 l +23.790121 30.196133 l +23.790121 31.317715 23.035217 32.051018 21.956793 32.051018 c +20.899923 32.051018 20.166574 31.296114 20.166574 30.196133 c +20.166574 23.811855 l +13.782296 23.811855 l +12.660716 23.811855 11.927410 23.056950 11.927410 21.978525 c +11.927410 20.921658 12.682316 20.188307 13.782296 20.188307 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1347 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 44.000000 44.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001437 00000 n +0000001460 00000 n +0000001633 00000 n +0000001707 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1766 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index b10e249b2..d8f32572f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", - "green" : "255", - "red" : "255" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index edc0dce9a..d47050048 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.141", - "red" : "0.125" + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index 2388399df..d8f32572f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x43", - "green" : "0x36", - "red" : "0x32" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json new file mode 100644 index 000000000..d47050048 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2B", + "green" : "0x23", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 80e8cf875..1537215d0 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -66,7 +66,7 @@ extension ComposeStatusContentCollectionViewCell { textEditorView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), textEditorView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), textEditorView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 20), + contentView.bottomAnchor.constraint(equalTo: textEditorView.bottomAnchor, constant: 10), textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift new file mode 100644 index 000000000..131af21e7 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift @@ -0,0 +1,120 @@ +// +// ComposeStatusNewPollOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit + +protocol ComposeStatusNewPollOptionCollectionViewCellDelegate: class { + func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) +} + +final class ComposeStatusNewPollOptionCollectionViewCell: UICollectionViewCell { + + let pollOptionView = PollOptionView() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + override var isHighlighted: Bool { + didSet { + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color + pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color + } + } + + weak var delegate: ComposeStatusNewPollOptionCollectionViewCellDelegate? + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusNewPollOptionCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.checkmarkBackgroundView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.isHidden = true + pollOptionView.plusCircleImageView.isHidden = false + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusNewPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + } + + private func setupBorderColor() { + pollOptionView.roundedBackgroundView.layer.borderWidth = 1 + pollOptionView.roundedBackgroundView.layer.borderColor = Asset.Colors.Background.secondarySystemBackground.color.cgColor + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusNewPollOptionCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.ComposeStatusNewPollOptionCollectionViewCellDidPressed(self) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusNewPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusNewPollOptionCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift new file mode 100644 index 000000000..3d930682b --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -0,0 +1,147 @@ +// +// ComposeStatusPollOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) +} + +final class ComposeStatusPollOptionCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + weak var delegate: ComposeStatusPollOptionCollectionViewCellDelegate? + + let pollOptionView = PollOptionView() + + let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + private var pollOptionSubscription: AnyCancellable? + let pollOption = PassthroughSubject() + + override func prepareForReuse() { + super.prepareForReuse() + + delegate = nil + disposeBag.removeAll() + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + private func _init() { + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) + NSLayoutConstraint.activate([ + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + pollOptionView.checkmarkImageView.isHidden = true + pollOptionView.optionPercentageLabel.isHidden = true + pollOptionView.optionTextField.text = nil + + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + pollOptionView.checkmarkBackgroundView.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color + setupBorderColor() + + pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + + pollOptionSubscription = NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: pollOptionView.optionTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self = self else { return } + guard let textField = notification.object as? UITextField else { return } + self.pollOption.send(textField.text ?? "") + } + pollOptionView.optionTextField.deleteBackwardDelegate = self + pollOptionView.optionTextField.delegate = self + } + + private func setupBorderColor() { + pollOptionView.checkmarkBackgroundView.layer.borderColor = UIColor.systemGray3.cgColor + pollOptionView.checkmarkBackgroundView.layer.borderWidth = 1 + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setupBorderColor() + } + +} + +extension ComposeStatusPollOptionCollectionViewCell { + + @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + pollOptionView.optionTextField.becomeFirstResponder() + } + +} + +// MARK: - DeleteBackwardResponseTextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextFieldDelegate { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) { + delegate?.composeStatusPollOptionCollectionViewCell(self, textBeforeDeleteBackward: textBeforeDelete) + } +} + +// MARK: - UITextFieldDelegate +extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + if textField === pollOptionView.optionTextField { + delegate?.composeStatusPollOptionCollectionViewCell(self, pollOptionTextFieldDidReturn: textField) + } + return true + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ComposeStatusPollOptionCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = ComposeStatusPollOptionCollectionViewCell() + return cell + } + .previewLayout(.fixed(width: 375, height: 44 + 10)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 760496026..3af0cb9e2 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -45,6 +45,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) + collectionView.register(ComposeStatusNewPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self)) collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color return collectionView }() @@ -154,7 +156,9 @@ extension ComposeViewController { for: collectionView, dependency: self, textEditorViewTextAttributesDelegate: self, - composeStatusAttachmentTableViewCellDelegate: self + composeStatusAttachmentTableViewCellDelegate: self, + composeStatusPollOptionCollectionViewCellDelegate: self, + composeStatusNewPollOptionCollectionViewCellDelegate: self ) // respond scrollView overlap change @@ -205,6 +209,16 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) + + viewModel.isMediaToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) + .store(in: &disposeBag) + + viewModel.isPollToolbarButtonEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: composeToolbarView.pollButton) + .store(in: &disposeBag) // bind custom emojis viewModel.customEmojiViewModel @@ -268,6 +282,57 @@ extension ComposeViewController { textEditorView()?.isEditing = true } + private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { + guard case .poll = item else { return nil } + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { + return nil + } + + return cell + } + + private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let firstPollItem = items.first { item -> Bool in + guard case .poll = item else { return false } + return true + } + + guard let item = firstPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + let lastPollItem = items.last { item -> Bool in + guard case .poll = item else { return false } + return true + } + + guard let item = lastPollItem else { + return nil + } + + return pollOptionCollectionViewCell(of: item) + } + + private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = firstPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + + private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { + guard let cell = lastPollOptionCollectionViewCell() else { return } + cell.pollOptionView.optionTextField.becomeFirstResponder() + } + private func showDismissConfirmAlertController() { let alertController = UIAlertController( title: L10n.Common.Alerts.DiscardPostContent.title, @@ -490,7 +555,6 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { extension ComposeViewController: ComposeToolbarViewDelegate { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, mediaSelectionType.rawValue) switch mediaSelectionType { case .photoLibrary: present(imagePicker, animated: true, completion: nil) @@ -501,20 +565,31 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) { + viewModel.isPollComposing.value.toggle() + + // setup initial poll option if needs + if viewModel.isPollComposing.value, viewModel.pollAttributes.value.isEmpty { + viewModel.pollAttributes.value = [ComposeStatusItem.ComposePollAttribute(), ComposeStatusItem.ComposePollAttribute()] + } + + if viewModel.isPollComposing.value { + // Magic RunLoop + DispatchQueue.main.async { + self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + markTextEditorViewBecomeFirstResponser() + } } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) { } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) { } } @@ -622,3 +697,88 @@ extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelega } } + +// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { + + // handle delete backward event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { + guard (text ?? "").isEmpty else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .poll(attribute) = item else { return } + + var pollAttributes = viewModel.pollAttributes.value + guard let index = pollAttributes.firstIndex(of: attribute) else { return } + + // mark previous (fallback to next) item of removed middle poll option become first responder + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) + if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { + func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index > 0 else { return nil } + let indexBeforeRemoved = pollItems.index(before: indexOfItem) + let itemBeforeRemoved = pollItems[indexBeforeRemoved] + return pollOptionCollectionViewCell(of: itemBeforeRemoved) + } + + func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { + guard index < pollItems.count - 1 else { return nil } + let indexAfterRemoved = pollItems.index(after: index) + let itemAfterRemoved = pollItems[indexAfterRemoved] + return pollOptionCollectionViewCell(of: itemAfterRemoved) + } + + var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() + if cell == nil { + cell = cellAfterRemoved() + } + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + + guard pollAttributes.count > 2 else { + return + } + pollAttributes.remove(at: index) + + // update data source + viewModel.pollAttributes.value = pollAttributes + } + + // handle keyboard return event for poll option input + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = collectionView.indexPath(for: cell) else { return } + let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in + guard case .poll = item else { return false } + return true + } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard let index = pollItems.firstIndex(of: item) else { return } + + if index == pollItems.count - 1 { + // is the last + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } else { + // not the last + let indexAfter = pollItems.index(after: index) + let itemAfter = pollItems[indexAfter] + let cell = pollOptionCollectionViewCell(of: itemAfter) + cell?.pollOptionView.optionTextField.becomeFirstResponder() + } + } + +} + +// MARK: - ComposeStatusNewPollOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusNewPollOptionCollectionViewCellDelegate { + func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) { + viewModel.createNewPollOptionIfPossible() + DispatchQueue.main.async { + self.markLastPollOptionCollectionViewCellBecomeFirstResponser() + } + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 389d23edb..c838f3e25 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -14,7 +14,9 @@ extension ComposeViewModel { for collectionView: UICollectionView, dependency: NeedsDependency, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate ) { let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( for: collectionView, @@ -22,7 +24,9 @@ extension ComposeViewModel { managedObjectContext: context.managedObjectContext, composeKind: composeKind, textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, - composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate + composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, + composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, + composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate ) // Note: do not allow reorder due to the images display order following the upload time @@ -48,7 +52,7 @@ extension ComposeViewModel { self.diffableDataSource = diffableDataSource var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.repliedTo, .status, .attachment]) + snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) switch composeKind { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index ea998b778..262d9bd54 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -19,6 +19,7 @@ final class ComposeViewModel { let context: AppContext let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() + let isPollComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject @@ -41,6 +42,8 @@ final class ComposeViewModel { let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) let isPublishBarButtonItemEnabled = CurrentValueSubject(false) + let isMediaToolbarButtonEnabled = CurrentValueSubject(true) + let isPollToolbarButtonEnabled = CurrentValueSubject(true) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) @@ -48,6 +51,9 @@ final class ComposeViewModel { // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) + // polls + let pollAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollAttribute], Never>([]) + init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -137,49 +143,91 @@ final class ComposeViewModel { } .store(in: &disposeBag) - // bind snapshot and drive service upload state - attachmentServices - .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - var snapshot = diffableDataSource.snapshot() - - snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) - var items: [ComposeStatusItem] = [] - for attachmentService in attachmentServices { - let item = ComposeStatusItem.attachment(attachmentService: attachmentService) - items.append(item) + // bind snapshot + Publishers.CombineLatest3( + attachmentServices.eraseToAnyPublisher(), + isPollComposing.eraseToAnyPublisher(), + pollAttributes.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .attachment)) + var attachmentItems: [ComposeStatusItem] = [] + for attachmentService in attachmentServices { + let item = ComposeStatusItem.attachment(attachmentService: attachmentService) + attachmentItems.append(item) + } + snapshot.appendItems(attachmentItems, toSection: .attachment) + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .poll)) + if isPollComposing { + var pollItems: [ComposeStatusItem] = [] + for pollAttribute in pollAttributes { + let item = ComposeStatusItem.poll(attribute: pollAttribute) + pollItems.append(item) } - snapshot.appendItems(items, toSection: .attachment) - - diffableDataSource.apply(snapshot) - - // make image upload in the queue - for attachmentService in attachmentServices { - // skip when prefix N task when task finish OR fail OR uploading - guard let currentState = attachmentService.uploadStateMachine.currentState else { break } - if currentState is MastodonAttachmentService.UploadState.Fail { - continue - } - if currentState is MastodonAttachmentService.UploadState.Finish { - continue - } - if currentState is MastodonAttachmentService.UploadState.Uploading { - break - } - // trigger uploading one by one - if currentState is MastodonAttachmentService.UploadState.Initial { - attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) - break - } + snapshot.appendItems(pollItems, toSection: .poll) + if pollAttributes.count < 4 { + snapshot.appendItems([ComposeStatusItem.newPoll], toSection: .poll) } } - .store(in: &disposeBag) + + diffableDataSource.apply(snapshot) + + // drive service upload state + // make image upload in the queue + for attachmentService in attachmentServices { + // skip when prefix N task when task finish OR fail OR uploading + guard let currentState = attachmentService.uploadStateMachine.currentState else { break } + if currentState is MastodonAttachmentService.UploadState.Fail { + continue + } + if currentState is MastodonAttachmentService.UploadState.Finish { + continue + } + if currentState is MastodonAttachmentService.UploadState.Uploading { + break + } + // trigger uploading one by one + if currentState is MastodonAttachmentService.UploadState.Initial { + attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) + break + } + } + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + isPollComposing.eraseToAnyPublisher(), + attachmentServices.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in + guard let self = self else { return } + let shouldMediaDisable = isPollComposing || attachmentServices.count >= 4 + let shouldPollDisable = attachmentServices.count > 0 + + self.isMediaToolbarButtonEnabled.value = !shouldMediaDisable + self.isPollToolbarButtonEnabled.value = !shouldPollDisable + }) + .store(in: &disposeBag) } } +extension ComposeViewModel { + func createNewPollOptionIfPossible() { + guard pollAttributes.value.count < 4 else { return } + + let attribute = ComposeStatusItem.ComposePollAttribute() + pollAttributes.value = pollAttributes.value + [attribute] + } +} + // MARK: - MastodonAttachmentServiceDelegate extension ComposeViewModel: MastodonAttachmentServiceDelegate { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index dfbc70cb9..b109faf3e 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -5,14 +5,15 @@ // Created by MainasuK Cirno on 2021-3-12. // +import os.log import UIKit protocol ComposeToolbarViewDelegate: class { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, gifButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, atButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, topicButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, locationButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) } final class ComposeToolbarView: UIView { @@ -102,10 +103,10 @@ extension ComposeToolbarView { mediaButton.menu = createMediaContextMenu() mediaButton.showsMenuAsPrimaryAction = true - pollButton.addTarget(self, action: #selector(ComposeToolbarView.gifButtonDidPressed(_:)), for: .touchUpInside) - emojiButton.addTarget(self, action: #selector(ComposeToolbarView.atButtonDidPressed(_:)), for: .touchUpInside) - contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.topicButtonDidPressed(_:)), for: .touchUpInside) - visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.locationButtonDidPressed(_:)), for: .touchUpInside) + pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) + emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) + contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.visibilityButtonDidPressed(_:)), for: .touchUpInside) } } @@ -131,18 +132,21 @@ extension ComposeToolbarView { var children: [UIMenuElement] = [] let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .photoLibrary) } children.append(photoLibraryAction) if UIImagePickerController.isSourceTypeAvailable(.camera) { let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .camera) }) children.append(cameraAction) } let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) self.delegate?.composeToolbarView(self, cameraButtonDidPressed: self.mediaButton, mediaSelectionType: .browse) } children.append(browseAction) @@ -155,20 +159,24 @@ extension ComposeToolbarView { extension ComposeToolbarView { - @objc private func gifButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, gifButtonDidPressed: sender) + @objc private func pollButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, pollButtonDidPressed: sender) } - @objc private func atButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, atButtonDidPressed: sender) + @objc private func emojiButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, emojiButtonDidPressed: sender) } - @objc private func topicButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, topicButtonDidPressed: sender) + @objc private func contentWarningButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) } - @objc private func locationButtonDidPressed(_ sender: UIButton) { - delegate?.composeToolbarView(self, locationButtonDidPressed: sender) + @objc private func visibilityButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeToolbarView(self, visibilityButtonDidPressed: sender) } } diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift new file mode 100644 index 000000000..4e5e5a2ae --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -0,0 +1,200 @@ +// +// PollOptionView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit +import Combine + +final class PollOptionView: UIView { + + static let height: CGFloat = optionHeight + 2 * verticalMargin + static let optionHeight: CGFloat = 44 + static let verticalMargin: CGFloat = 5 + static let checkmarkImageSize = CGSize(width: 26, height: 26) + + private var viewStateDisposeBag = Set() + + let roundedBackgroundView = UIView() + let voteProgressStripView: StripProgressView = { + let view = StripProgressView() + view.tintColor = Asset.Colors.Background.Poll.highlight.color + return view + }() + + let checkmarkBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = .systemBackground + return view + }() + + let checkmarkImageView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let plusCircleImageView: UIImageView = { + let imageView = UIImageView() + let image = Asset.Circles.plusCircle.image + imageView.image = image.withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Button.normal.color + return imageView + }() + + let optionTextField: DeleteBackwardResponseTextField = { + let textField = DeleteBackwardResponseTextField() + textField.font = .systemFont(ofSize: 15, weight: .medium) + textField.textColor = Asset.Colors.Label.primary.color + textField.text = "Option" + textField.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right + return textField + }() + + let optionLabelMiddlePaddingView = UIView() + + let optionPercentageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = "50%" + label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension PollOptionView { + private func _init() { + roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false + addSubview(roundedBackgroundView) + NSLayoutConstraint.activate([ + roundedBackgroundView.topAnchor.constraint(equalTo: topAnchor, constant: 5), + roundedBackgroundView.leadingAnchor.constraint(equalTo: leadingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), + roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.optionHeight).priority(.defaultHigh), + ]) + + voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(voteProgressStripView) + NSLayoutConstraint.activate([ + voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), + voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), + voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), + voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), + ]) + + checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(checkmarkBackgroundView) + NSLayoutConstraint.activate([ + checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), + roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), + checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.width).priority(.required - 1), + checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.height).priority(.required - 1), + ]) + + checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false + checkmarkBackgroundView.addSubview(checkmarkImageView) + NSLayoutConstraint.activate([ + checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), + checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), + checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), + checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), + ]) + + plusCircleImageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(plusCircleImageView) + NSLayoutConstraint.activate([ + plusCircleImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor), + plusCircleImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor), + plusCircleImageView.trailingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor), + plusCircleImageView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor), + ]) + + optionTextField.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionTextField) + NSLayoutConstraint.activate([ + optionTextField.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), + optionTextField.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + + optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) + NSLayoutConstraint.activate([ + optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionTextField.trailingAnchor), + optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), + optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), + ]) + optionLabelMiddlePaddingView.setContentHuggingPriority(.required - 1, for: .horizontal) + optionLabelMiddlePaddingView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false + roundedBackgroundView.addSubview(optionPercentageLabel) + NSLayoutConstraint.activate([ + optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), + roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), + optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), + ]) + optionPercentageLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + + plusCircleImageView.isHidden = true + } + + override func layoutSubviews() { + super.layoutSubviews() + updateCornerRadius() + } + +} + +extension PollOptionView { + private func updateCornerRadius() { + roundedBackgroundView.layer.masksToBounds = true + roundedBackgroundView.layer.cornerRadius = PollOptionView.optionHeight * 0.5 + roundedBackgroundView.layer.cornerCurve = .circular + + checkmarkBackgroundView.layer.masksToBounds = true + checkmarkBackgroundView.layer.cornerRadius = PollOptionView.checkmarkImageSize.width * 0.5 + checkmarkBackgroundView.layer.cornerCurve = .circular + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PollOptionView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + PollOptionView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index 2fd3a023d..b067896a1 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -10,54 +10,8 @@ import Combine final class PollOptionTableViewCell: UITableViewCell { - static let height: CGFloat = optionHeight + 2 * verticalMargin - static let optionHeight: CGFloat = 44 - static let verticalMargin: CGFloat = 5 - static let checkmarkImageSize = CGSize(width: 26, height: 26) - - private var viewStateDisposeBag = Set() + let pollOptionView = PollOptionView() var attribute: PollItem.Attribute? - - let roundedBackgroundView = UIView() - let voteProgressStripView: StripProgressView = { - let view = StripProgressView() - view.tintColor = Asset.Colors.Background.Poll.highlight.color - return view - }() - - let checkmarkBackgroundView: UIView = { - let view = UIView() - view.backgroundColor = .systemBackground - return view - }() - - let checkmarkImageView: UIView = { - let imageView = UIImageView() - let image = UIImage(systemName: "checkmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .bold))! - imageView.image = image.withRenderingMode(.alwaysTemplate) - imageView.tintColor = Asset.Colors.Button.normal.color - return imageView - }() - - let optionLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 15, weight: .medium) - label.textColor = Asset.Colors.Label.primary.color - label.text = "Option" - label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .left : .right - return label - }() - - let optionLabelMiddlePaddingView = UIView() - - let optionPercentageLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 13, weight: .regular) - label.textColor = Asset.Colors.Label.primary.color - label.text = "50%" - label.textAlignment = UIApplication.shared.userInterfaceLayoutDirection == .leftToRight ? .right : .left - return label - }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -76,7 +30,7 @@ final class PollOptionTableViewCell: UITableViewCell { switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color - self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color case .reveal: break } @@ -89,7 +43,7 @@ final class PollOptionTableViewCell: UITableViewCell { switch voteState { case .hidden: let color = Asset.Colors.Background.systemGroupedBackground.color - self.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? color.withAlphaComponent(0.8) : color case .reveal: break } @@ -102,125 +56,55 @@ extension PollOptionTableViewCell { private func _init() { selectionStyle = .none backgroundColor = .clear - roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + pollOptionView.optionTextField.isUserInteractionEnabled = false - roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(roundedBackgroundView) + pollOptionView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(pollOptionView) NSLayoutConstraint.activate([ - roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 5), - roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor, constant: 5), - roundedBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.optionHeight).priority(.defaultHigh), + pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), + pollOptionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + pollOptionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - - voteProgressStripView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(voteProgressStripView) - NSLayoutConstraint.activate([ - voteProgressStripView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor), - voteProgressStripView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor), - voteProgressStripView.trailingAnchor.constraint(equalTo: roundedBackgroundView.trailingAnchor), - voteProgressStripView.bottomAnchor.constraint(equalTo: roundedBackgroundView.bottomAnchor), - ]) - - checkmarkBackgroundView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(checkmarkBackgroundView) - NSLayoutConstraint.activate([ - checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), - checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), - roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), - checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.width).priority(.defaultHigh), - checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionTableViewCell.checkmarkImageSize.height).priority(.defaultHigh), - ]) - - checkmarkImageView.translatesAutoresizingMaskIntoConstraints = false - checkmarkBackgroundView.addSubview(checkmarkImageView) - NSLayoutConstraint.activate([ - checkmarkImageView.topAnchor.constraint(equalTo: checkmarkBackgroundView.topAnchor, constant: 5), - checkmarkImageView.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.leadingAnchor, constant: 5), - checkmarkBackgroundView.trailingAnchor.constraint(equalTo: checkmarkImageView.trailingAnchor, constant: 5), - checkmarkBackgroundView.bottomAnchor.constraint(equalTo: checkmarkImageView.bottomAnchor, constant: 5), - ]) - - optionLabel.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionLabel) - NSLayoutConstraint.activate([ - optionLabel.leadingAnchor.constraint(equalTo: checkmarkBackgroundView.trailingAnchor, constant: 14), - optionLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - ]) - - optionLabelMiddlePaddingView.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionLabelMiddlePaddingView) - NSLayoutConstraint.activate([ - optionLabelMiddlePaddingView.leadingAnchor.constraint(equalTo: optionLabel.trailingAnchor), - optionLabelMiddlePaddingView.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - optionLabelMiddlePaddingView.heightAnchor.constraint(equalToConstant: 4).priority(.defaultHigh), - optionLabelMiddlePaddingView.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).priority(.defaultLow), - ]) - optionLabelMiddlePaddingView.setContentHuggingPriority(.defaultLow, for: .horizontal) - - optionPercentageLabel.translatesAutoresizingMaskIntoConstraints = false - roundedBackgroundView.addSubview(optionPercentageLabel) - NSLayoutConstraint.activate([ - optionPercentageLabel.leadingAnchor.constraint(equalTo: optionLabelMiddlePaddingView.trailingAnchor), - roundedBackgroundView.trailingAnchor.constraint(equalTo: optionPercentageLabel.trailingAnchor, constant: 18), - optionPercentageLabel.centerYAnchor.constraint(equalTo: roundedBackgroundView.centerYAnchor), - ]) - optionPercentageLabel.setContentHuggingPriority(.required - 1, for: .horizontal) - optionPercentageLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) } - - override func layoutSubviews() { - super.layoutSubviews() - - updateCornerRadius() - updateTextAppearance() - } - - private func updateCornerRadius() { - roundedBackgroundView.layer.masksToBounds = true - roundedBackgroundView.layer.cornerRadius = PollOptionTableViewCell.optionHeight * 0.5 - roundedBackgroundView.layer.cornerCurve = .circular - - checkmarkBackgroundView.layer.masksToBounds = true - checkmarkBackgroundView.layer.cornerRadius = PollOptionTableViewCell.checkmarkImageSize.width * 0.5 - checkmarkBackgroundView.layer.cornerCurve = .circular - } - + func updateTextAppearance() { guard let voteState = attribute?.voteState else { - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() return } switch voteState { case .hidden: - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() case .reveal(_, let percentage, _): - if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.minX { - optionLabel.textColor = .white - optionLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.minX { + pollOptionView.optionTextField.textColor = .white + pollOptionView.optionTextField.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) } else { - optionLabel.textColor = Asset.Colors.Label.primary.color - optionLabel.layer.removeShadow() + pollOptionView.optionTextField.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionTextField.layer.removeShadow() } - if CGFloat(percentage) * voteProgressStripView.frame.width > optionLabelMiddlePaddingView.frame.maxX { - optionPercentageLabel.textColor = .white - optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) + if CGFloat(percentage) * pollOptionView.voteProgressStripView.frame.width > pollOptionView.optionLabelMiddlePaddingView.frame.maxX { + pollOptionView.optionPercentageLabel.textColor = .white + pollOptionView.optionPercentageLabel.layer.setupShadow(x: 0, y: 0, blur: 4, spread: 0) } else { - optionPercentageLabel.textColor = Asset.Colors.Label.primary.color - optionPercentageLabel.layer.removeShadow() + pollOptionView.optionPercentageLabel.textColor = Asset.Colors.Label.primary.color + pollOptionView.optionPercentageLabel.layer.removeShadow() } } - + } + + override func layoutSubviews() { + super.layoutSubviews() + updateTextAppearance() } } - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift new file mode 100644 index 000000000..21c80dcf8 --- /dev/null +++ b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift @@ -0,0 +1,24 @@ +// +// DeleteBackwardResponseTextField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-23. +// + +import UIKit + +protocol DeleteBackwardResponseTextFieldDelegate: class { + func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) +} + +final class DeleteBackwardResponseTextField: UITextField { + + weak var deleteBackwardDelegate: DeleteBackwardResponseTextFieldDelegate? + + override func deleteBackward() { + let text = self.text + super.deleteBackward() + deleteBackwardDelegate?.deleteBackwardResponseTextField(self, textBeforeDelete: text) + } + +} From 3eb2b916a7165f182382d5a8197c433ee841b7fd Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 23 Mar 2021 19:33:12 +0800 Subject: [PATCH 137/400] feat: add post publish validate state binding --- .../Diffiable/Item/ComposeStatusItem.swift | 22 ++++++- .../Scene/Compose/ComposeViewController.swift | 3 - Mastodon/Scene/Compose/ComposeViewModel.swift | 57 ++++++++++++++++++- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 5d6e12fa2..6d225e7d0 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -25,7 +25,7 @@ extension ComposeStatusItem: Hashable { } extension ComposeStatusItem { final class ComposeStatusAttribute: Equatable, Hashable { private let id = UUID() - + let avatarURL = CurrentValueSubject(nil) let displayName = CurrentValueSubject(nil) let username = CurrentValueSubject(nil) @@ -44,12 +44,32 @@ extension ComposeStatusItem { } } +protocol ComposeStatusItemDelegate: class { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollAttribute, pollOptionDidChange: String?) +} + extension ComposeStatusItem { final class ComposePollAttribute: Equatable, Hashable { private let id = UUID() + var disposeBag = Set() + weak var delegate: ComposeStatusItemDelegate? + let option = CurrentValueSubject("") + init() { + option + .sink { [weak self] option in + guard let self = self else { return } + self.delegate?.composePollAttribute(self, pollOptionDidChange: option) + } + .store(in: &disposeBag) + } + + deinit { + disposeBag.removeAll() + } + static func == (lhs: ComposePollAttribute, rhs: ComposePollAttribute) -> Bool { return lhs.id == rhs.id && lhs.option.value == rhs.option.value diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 3af0cb9e2..54de777ed 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -629,7 +629,6 @@ extension ComposeViewController: PHPickerViewControllerDelegate { pickerResult: result, initalAuthenticationBox: viewModel.activeAuthenticationBox.value ) - service.delegate = viewModel return service } viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices @@ -649,7 +648,6 @@ extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationC image: image, initalAuthenticationBox: viewModel.activeAuthenticationBox.value ) - attachmentService.delegate = viewModel viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] } @@ -673,7 +671,6 @@ extension ComposeViewController: UIDocumentPickerDelegate { imageData: imageData, initalAuthenticationBox: viewModel.activeAuthenticationBox.value ) - attachmentService.delegate = viewModel viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService] } catch { os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 262d9bd54..4c49117f1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -104,19 +104,48 @@ final class ComposeViewModel { .map { services in services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } - Publishers.CombineLatest4( + let isPollAttributeAllValid = pollAttributes + .map { pollAttributes in + pollAttributes.allSatisfy { attribute -> Bool in + !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( isComposeContentEmpty.eraseToAnyPublisher(), isComposeContentValid.eraseToAnyPublisher(), isMediaEmpty.eraseToAnyPublisher(), isMediaUploadAllSuccess.eraseToAnyPublisher() ) - .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess in + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in if isMediaEmpty { return isComposeContentValid && !isComposeContentEmpty } else { return isComposeContentValid && isMediaUploadAllSuccess } } + .eraseToAnyPublisher() + + let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( + isComposeContentEmpty.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isPollComposing.eraseToAnyPublisher(), + isPollAttributeAllValid.eraseToAnyPublisher() + ) + .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in + if isPollComposing { + return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid + } else { + return isComposeContentValid && !isComposeContentEmpty + } + } + .eraseToAnyPublisher() + + Publishers.CombineLatest( + isPublishBarButtonItemEnabledPrecondition1, + isPublishBarButtonItemEnabledPrecondition2 + ) + .map { $0 && $1 } .assign(to: \.value, on: isPublishBarButtonItemEnabled) .store(in: &disposeBag) @@ -201,6 +230,22 @@ final class ComposeViewModel { } .store(in: &disposeBag) + // bind delegate + attachmentServices + .sink { [weak self] attachmentServices in + guard let self = self else { return } + attachmentServices.forEach { $0.delegate = self } + } + .store(in: &disposeBag) + + pollAttributes + .sink { [weak self] pollAttributes in + guard let self = self else { return } + pollAttributes.forEach { $0.delegate = self } + } + .store(in: &disposeBag) + + // bind compose toolbar UI state Publishers.CombineLatest( isPollComposing.eraseToAnyPublisher(), attachmentServices.eraseToAnyPublisher() @@ -235,3 +280,11 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { attachmentServices.value = attachmentServices.value } } + +// MARK: - ComposeStatusAttributeDelegate +extension ComposeViewModel: ComposeStatusItemDelegate { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollAttribute, pollOptionDidChange: String?) { + // trigger update + pollAttributes.value = pollAttributes.value + } +} From d05f97951b1a276d2eb3d50e88707da093c8294e Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 24 Mar 2021 14:49:27 +0800 Subject: [PATCH 138/400] feat: add expires duration selector for poll --- Localization/app.json | 9 +++ Mastodon.xcodeproj/project.pbxproj | 12 ++- .../Diffiable/Item/ComposeStatusItem.swift | 64 ++++++++++++++-- .../Section/ComposeStatusSection.swift | 20 ++++- Mastodon/Generated/Strings.swift | 18 +++++ .../Resources/en.lproj/Localizable.strings | 7 ++ ...sPollExpiresOptionCollectionViewCell.swift | 74 +++++++++++++++++++ ...OptionAppendEntryCollectionViewCell.swift} | 20 ++--- .../Scene/Compose/ComposeViewController.swift | 31 +++++--- .../Compose/ComposeViewModel+Diffable.swift | 6 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 16 ++-- .../Share/View/Content/PollOptionView.swift | 3 +- 12 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift rename Mastodon/Scene/Compose/CollectionViewCell/{ComposeStatusNewPollOptionCollectionViewCell.swift => ComposeStatusPollOptionAppendEntryCollectionViewCell.swift} (80%) diff --git a/Localization/app.json b/Localization/app.json index 0cd2e7f83..3a3db1300 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -207,6 +207,15 @@ "attachment_broken": "This %s is broken and can't be\nuploaded to Mastodon.", "description_photo": "Describe photo for low vision people...", "description_video": "Describe what’s happening for low vision people..." + }, + "poll": { + "duration_time": "Duration: %s", + "thirty_minutes": "30 minutes", + "one_hour": "1 Hour", + "six_hours": "6 Hours", + "one_day": "1 Day", + "three_days": "3 Days", + "seven_days": "7 Days" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 50f779c64..6b719bc71 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; + DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -189,7 +190,7 @@ DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; - DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */; }; + DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */; }; DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */; }; DB89B9F725C10FD0008580ED /* CoreDataStack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */; }; DB89B9FE25C10FD0008580ED /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89B9FD25C10FD0008580ED /* CoreDataStackTests.swift */; }; @@ -418,6 +419,7 @@ DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; + DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -487,7 +489,7 @@ DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; - DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusNewPollOptionCollectionViewCell.swift; sourceTree = ""; }; + DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionAppendEntryCollectionViewCell.swift; sourceTree = ""; }; DB87D4562609DD5300D12C0D /* DeleteBackwardResponseTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteBackwardResponseTextField.swift; sourceTree = ""; }; DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CoreDataStack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB89B9F025C10FD0008580ED /* CoreDataStack.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; @@ -1163,7 +1165,8 @@ DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, - DB87D4502609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift */, + DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, + DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -1826,6 +1829,7 @@ DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, @@ -1953,7 +1957,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, - DB87D4512609CF1E00D12C0D /* ComposeStatusNewPollOptionCollectionViewCell.swift in Sources */, + DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 6d225e7d0..86e8c6228 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -14,8 +14,9 @@ enum ComposeStatusItem { case replyTo(statusObjectID: NSManagedObjectID) case input(replyToStatusObjectID: NSManagedObjectID?, attribute: ComposeStatusAttribute) case attachment(attachmentService: MastodonAttachmentService) - case poll(attribute: ComposePollAttribute) - case newPoll + case pollOption(attribute: ComposePollOptionAttribute) + case pollOptionAppendEntry + case pollExpiresOption(attribute: ComposePollExpiresOptionAttribute) } extension ComposeStatusItem: Equatable { } @@ -44,16 +45,16 @@ extension ComposeStatusItem { } } -protocol ComposeStatusItemDelegate: class { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollAttribute, pollOptionDidChange: String?) +protocol ComposePollAttributeDelegate: class { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) } extension ComposeStatusItem { - final class ComposePollAttribute: Equatable, Hashable { + final class ComposePollOptionAttribute: Equatable, Hashable { private let id = UUID() var disposeBag = Set() - weak var delegate: ComposeStatusItemDelegate? + weak var delegate: ComposePollAttributeDelegate? let option = CurrentValueSubject("") @@ -70,7 +71,7 @@ extension ComposeStatusItem { disposeBag.removeAll() } - static func == (lhs: ComposePollAttribute, rhs: ComposePollAttribute) -> Bool { + static func == (lhs: ComposePollOptionAttribute, rhs: ComposePollOptionAttribute) -> Bool { return lhs.id == rhs.id && lhs.option.value == rhs.option.value } @@ -80,3 +81,52 @@ extension ComposeStatusItem { } } } + +extension ComposeStatusItem { + final class ComposePollExpiresOptionAttribute: Equatable, Hashable { + private let id = UUID() + + let expiresOption = CurrentValueSubject(.thirtyMinutes) + + + static func == (lhs: ComposePollExpiresOptionAttribute, rhs: ComposePollExpiresOptionAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.expiresOption.value == rhs.expiresOption.value + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + enum ExpiresOption: Equatable, Hashable, CaseIterable { + case thirtyMinutes + case oneHour + case sixHours + case oneDay + case threeDays + case sevenDays + + var title: String { + switch self { + case .thirtyMinutes: return L10n.Scene.Compose.Poll.thirtyMinutes + case .oneHour: return L10n.Scene.Compose.Poll.oneHour + case .sixHours: return L10n.Scene.Compose.Poll.sixHours + case .oneDay: return L10n.Scene.Compose.Poll.oneDay + case .threeDays: return L10n.Scene.Compose.Poll.threeDays + case .sevenDays: return L10n.Scene.Compose.Poll.sevenDays + } + } + + var seconds: Int { + switch self { + case .thirtyMinutes: return 60 * 30 + case .oneHour: return 60 * 60 * 1 + case .sixHours: return 60 * 60 * 6 + case .oneDay: return 60 * 60 * 24 + case .threeDays: return 60 * 60 * 24 * 3 + case .sevenDays: return 60 * 60 * 24 * 7 + } + } + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index e8fee6d47..86bf9bd75 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -36,7 +36,8 @@ extension ComposeStatusSection { textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in switch item { @@ -127,7 +128,7 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) return cell - case .poll(let attribute): + case .pollOption(let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell cell.pollOptionView.optionTextField.text = attribute.option.value cell.pollOption @@ -136,10 +137,21 @@ extension ComposeStatusSection { .store(in: &cell.disposeBag) cell.delegate = composeStatusPollOptionCollectionViewCellDelegate return cell - case .newPoll: - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusNewPollOptionCollectionViewCell + case .pollOptionAppendEntry: + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell cell.delegate = composeStatusNewPollOptionCollectionViewCellDelegate return cell + case .pollExpiresOption(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollExpiresOptionCollectionViewCell + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(attribute.expiresOption.value.title), for: .normal) + attribute.expiresOption + .receive(on: DispatchQueue.main) + .sink { expiresOption in + cell.durationButton.setTitle(L10n.Scene.Compose.Poll.durationTime(expiresOption.title), for: .normal) + } + .store(in: &cell.disposeBag) + cell.delegate = composeStatusPollExpiresOptionCollectionViewCellDelegate + return cell } } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7f142cb99..86c3bf13e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -170,6 +170,24 @@ internal enum L10n { /// Photo Library internal static let photoLibrary = L10n.tr("Localizable", "Scene.Compose.MediaSelection.PhotoLibrary") } + internal enum Poll { + /// Duration: %@ + internal static func durationTime(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.Poll.DurationTime", String(describing: p1)) + } + /// 1 Day + internal static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay") + /// 1 Hour + internal static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour") + /// 7 Days + internal static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays") + /// 6 Hours + internal static let sixHours = L10n.tr("Localizable", "Scene.Compose.Poll.SixHours") + /// 30 minutes + internal static let thirtyMinutes = L10n.tr("Localizable", "Scene.Compose.Poll.ThirtyMinutes") + /// 3 Days + internal static let threeDays = L10n.tr("Localizable", "Scene.Compose.Poll.ThreeDays") + } internal enum Title { /// New Post internal static let newPost = L10n.tr("Localizable", "Scene.Compose.Title.NewPost") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 87649a3e0..dd34cbfe1 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -50,6 +50,13 @@ uploaded to Mastodon."; "Scene.Compose.MediaSelection.Browse" = "Browse"; "Scene.Compose.MediaSelection.Camera" = "Take Photo"; "Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; +"Scene.Compose.Poll.DurationTime" = "Duration: %@"; +"Scene.Compose.Poll.OneDay" = "1 Day"; +"Scene.Compose.Poll.OneHour" = "1 Hour"; +"Scene.Compose.Poll.SevenDays" = "7 Days"; +"Scene.Compose.Poll.SixHours" = "6 Hours"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 minutes"; +"Scene.Compose.Poll.ThreeDays" = "3 Days"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift new file mode 100644 index 000000000..0abe94ba0 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -0,0 +1,74 @@ +// +// ComposeStatusPollExpiresOptionCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import os.log +import UIKit +import Combine + +protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: class { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) +} + +final class ComposeStatusPollExpiresOptionCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + weak var delegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate? + + let durationButton: UIButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12)) + button.expandEdgeInsets = UIEdgeInsets(top: 0, left: -10, bottom: -20, right: -20) + button.setTitle(L10n.Scene.Compose.Poll.durationTime(L10n.Scene.Compose.Poll.thirtyMinutes), for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ComposeStatusPollExpiresOptionCollectionViewCell { + + private typealias ExpiresOption = ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption + + private func _init() { + durationButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(durationButton) + NSLayoutConstraint.activate([ + durationButton.topAnchor.constraint(equalTo: contentView.topAnchor), + durationButton.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin), + durationButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + + let children = ExpiresOption.allCases.map { expiresOption -> UIAction in + UIAction(title: expiresOption.title, image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in + guard let self = self else { return } + self.expiresOptionActionHandler(action, expiresOption: expiresOption) + } + } + durationButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + durationButton.showsMenuAsPrimaryAction = true + } + +} + +extension ComposeStatusPollExpiresOptionCollectionViewCell { + + private func expiresOptionActionHandler(_ sender: UIAction, expiresOption: ExpiresOption) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, expiresOption.title) + delegate?.composeStatusPollExpiresOptionCollectionViewCell(self, didSelectExpiresOption: expiresOption) + } + +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift similarity index 80% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift index 131af21e7..9479575b7 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusNewPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeStatusNewPollOptionCollectionViewCell.swift +// ComposeStatusPollOptionAppendEntryCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-23. @@ -8,11 +8,11 @@ import os.log import UIKit -protocol ComposeStatusNewPollOptionCollectionViewCellDelegate: class { - func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) +protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: class { + func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) } -final class ComposeStatusNewPollOptionCollectionViewCell: UICollectionViewCell { +final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionViewCell { let pollOptionView = PollOptionView() @@ -25,7 +25,7 @@ final class ComposeStatusNewPollOptionCollectionViewCell: UICollectionViewCell { } } - weak var delegate: ComposeStatusNewPollOptionCollectionViewCellDelegate? + weak var delegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? override func prepareForReuse() { super.prepareForReuse() @@ -45,7 +45,7 @@ final class ComposeStatusNewPollOptionCollectionViewCell: UICollectionViewCell { } -extension ComposeStatusNewPollOptionCollectionViewCell { +extension ComposeStatusPollOptionAppendEntryCollectionViewCell { private func _init() { pollOptionView.translatesAutoresizingMaskIntoConstraints = false @@ -67,7 +67,7 @@ extension ComposeStatusNewPollOptionCollectionViewCell { setupBorderColor() pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) - singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusNewPollOptionCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionAppendEntryCollectionViewCell.singleTagGestureRecognizerHandler(_:))) } private func setupBorderColor() { @@ -83,11 +83,11 @@ extension ComposeStatusNewPollOptionCollectionViewCell { } -extension ComposeStatusNewPollOptionCollectionViewCell { +extension ComposeStatusPollOptionAppendEntryCollectionViewCell { @objc private func singleTagGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.ComposeStatusNewPollOptionCollectionViewCellDidPressed(self) + delegate?.composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(self) } } @@ -100,7 +100,7 @@ struct ComposeStatusNewPollOptionCollectionViewCell_Previews: PreviewProvider { static var controls: some View { Group { UIViewPreview() { - let cell = ComposeStatusNewPollOptionCollectionViewCell() + let cell = ComposeStatusPollOptionAppendEntryCollectionViewCell() return cell } .previewLayout(.fixed(width: 375, height: 44 + 10)) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 54de777ed..95398ca1b 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -46,7 +46,8 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) - collectionView.register(ComposeStatusNewPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusNewPollOptionCollectionViewCell.self)) + collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) + collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color return collectionView }() @@ -158,7 +159,8 @@ extension ComposeViewController { textEditorViewTextAttributesDelegate: self, composeStatusAttachmentTableViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, - composeStatusNewPollOptionCollectionViewCellDelegate: self + composeStatusNewPollOptionCollectionViewCellDelegate: self, + composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) // respond scrollView overlap change @@ -283,7 +285,7 @@ extension ComposeViewController { } private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { - guard case .poll = item else { return nil } + guard case .pollOption = item else { return nil } guard let diffableDataSource = viewModel.diffableDataSource else { return nil } guard let indexPath = diffableDataSource.indexPath(for: item), let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { @@ -297,7 +299,7 @@ extension ComposeViewController { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) let firstPollItem = items.first { item -> Bool in - guard case .poll = item else { return false } + guard case .pollOption = item else { return false } return true } @@ -312,7 +314,7 @@ extension ComposeViewController { guard let diffableDataSource = viewModel.diffableDataSource else { return nil } let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll) let lastPollItem = items.last { item -> Bool in - guard case .poll = item else { return false } + guard case .pollOption = item else { return false } return true } @@ -570,7 +572,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate { // setup initial poll option if needs if viewModel.isPollComposing.value, viewModel.pollAttributes.value.isEmpty { - viewModel.pollAttributes.value = [ComposeStatusItem.ComposePollAttribute(), ComposeStatusItem.ComposePollAttribute()] + viewModel.pollAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()] } if viewModel.isPollComposing.value { @@ -704,7 +706,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let indexPath = collectionView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - guard case let .poll(attribute) = item else { return } + guard case let .pollOption(attribute) = item else { return } var pollAttributes = viewModel.pollAttributes.value guard let index = pollAttributes.firstIndex(of: attribute) else { return } @@ -747,7 +749,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let indexPath = collectionView.indexPath(for: cell) else { return } let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in - guard case .poll = item else { return false } + guard case .pollOption = item else { return false } return true } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } @@ -770,12 +772,19 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega } -// MARK: - ComposeStatusNewPollOptionCollectionViewCellDelegate -extension ComposeViewController: ComposeStatusNewPollOptionCollectionViewCellDelegate { - func ComposeStatusNewPollOptionCollectionViewCellDidPressed(_ cell: ComposeStatusNewPollOptionCollectionViewCell) { +// MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { + func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { viewModel.createNewPollOptionIfPossible() DispatchQueue.main.async { self.markLastPollOptionCollectionViewCellBecomeFirstResponser() } } } + +// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate +extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { + func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) { + viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index c838f3e25..bfca4a39f 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -16,7 +16,8 @@ extension ComposeViewModel { textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusNewPollOptionCollectionViewCellDelegate + composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) { let diffableDataSource = ComposeStatusSection.collectionViewDiffableDataSource( for: collectionView, @@ -26,7 +27,8 @@ extension ComposeViewModel { textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, - composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate + composeStatusNewPollOptionCollectionViewCellDelegate: composeStatusNewPollOptionCollectionViewCellDelegate, + composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate ) // Note: do not allow reorder due to the images display order following the upload time diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 4c49117f1..e423312ef 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -52,7 +52,8 @@ final class ComposeViewModel { let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) // polls - let pollAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollAttribute], Never>([]) + let pollAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) + let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() init( context: AppContext, @@ -196,13 +197,14 @@ final class ComposeViewModel { if isPollComposing { var pollItems: [ComposeStatusItem] = [] for pollAttribute in pollAttributes { - let item = ComposeStatusItem.poll(attribute: pollAttribute) + let item = ComposeStatusItem.pollOption(attribute: pollAttribute) pollItems.append(item) } snapshot.appendItems(pollItems, toSection: .poll) if pollAttributes.count < 4 { - snapshot.appendItems([ComposeStatusItem.newPoll], toSection: .poll) + snapshot.appendItems([ComposeStatusItem.pollOptionAppendEntry], toSection: .poll) } + snapshot.appendItems([ComposeStatusItem.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)], toSection: .poll) } diffableDataSource.apply(snapshot) @@ -268,7 +270,7 @@ extension ComposeViewModel { func createNewPollOptionIfPossible() { guard pollAttributes.value.count < 4 else { return } - let attribute = ComposeStatusItem.ComposePollAttribute() + let attribute = ComposeStatusItem.ComposePollOptionAttribute() pollAttributes.value = pollAttributes.value + [attribute] } } @@ -281,9 +283,9 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { } } -// MARK: - ComposeStatusAttributeDelegate -extension ComposeViewModel: ComposeStatusItemDelegate { - func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollAttribute, pollOptionDidChange: String?) { +// MARK: - ComposePollAttributeDelegate +extension ComposeViewModel: ComposePollAttributeDelegate { + func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update pollAttributes.value = pollAttributes.value } diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift index 4e5e5a2ae..eafeb55cf 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -14,6 +14,7 @@ final class PollOptionView: UIView { static let optionHeight: CGFloat = 44 static let verticalMargin: CGFloat = 5 static let checkmarkImageSize = CGSize(width: 26, height: 26) + static let checkmarkBackgroundLeadingMargin: CGFloat = 9 private var viewStateDisposeBag = Set() @@ -105,7 +106,7 @@ extension PollOptionView { roundedBackgroundView.addSubview(checkmarkBackgroundView) NSLayoutConstraint.activate([ checkmarkBackgroundView.topAnchor.constraint(equalTo: roundedBackgroundView.topAnchor, constant: 9), - checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: 9), + checkmarkBackgroundView.leadingAnchor.constraint(equalTo: roundedBackgroundView.leadingAnchor, constant: PollOptionView.checkmarkBackgroundLeadingMargin), roundedBackgroundView.bottomAnchor.constraint(equalTo: checkmarkBackgroundView.bottomAnchor, constant: 9), checkmarkBackgroundView.widthAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.width).priority(.required - 1), checkmarkBackgroundView.heightAnchor.constraint(equalToConstant: PollOptionView.checkmarkImageSize.height).priority(.required - 1), From 0e84b4c1645066aa1b63106673d40244f32ceb37 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 24 Mar 2021 15:08:00 +0800 Subject: [PATCH 139/400] feat: implement poll supports for status compose --- .../ComposeViewModel+PublishState.swift | 13 +++++++- .../API/Mastodon+API+Statuses.swift | 31 +++++++++++++++---- .../Query/MultipartFormValue.swift | 9 ++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 2da46b655..040ba26c7 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -53,6 +53,15 @@ extension ComposeViewModel.PublishState { let mediaIDs = attachmentServices.compactMap { attachmentService in attachmentService.attachment.value?.id } + let pollOptions: [String]? = { + guard viewModel.isPollComposing.value else { return nil } + return viewModel.pollAttributes.value.map { attribute in attribute.option.value } + }() + let pollExpiresIn: Int? = { + guard viewModel.isPollComposing.value else { return nil } + return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds + }() + let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { var subscriptions: [AnyPublisher, Error>] = [] for attachmentService in attachmentServices { @@ -81,7 +90,9 @@ extension ComposeViewModel.PublishState { .flatMap { attachments -> AnyPublisher, Error> in let query = Mastodon.API.Statuses.PublishStatusQuery( status: viewModel.composeStatusAttribute.composeContent.value, - mediaIDs: mediaIDs + mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, + pollOptions: pollOptions, + pollExpiresIn: pollExpiresIn ) return viewModel.context.apiService.publishStatus( domain: domain, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index 6a7642609..e4bff7d54 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -96,15 +96,34 @@ extension Mastodon.API.Statuses { public struct PublishStatusQuery: Codable, PostQuery { public let status: String? public let mediaIDs: [String]? + public let pollOptions: [String]? + public let pollExpiresIn: Int? - enum CodingKeys: String, CodingKey { - case status - case mediaIDs = "media_ids" - } - - public init(status: String?, mediaIDs: [String]?) { + public init(status: String?, mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?) { self.status = status self.mediaIDs = mediaIDs + self.pollOptions = pollOptions + self.pollExpiresIn = pollExpiresIn + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + status.flatMap { data.append(Data.multipart(key: "status", value: $0)) } + for mediaID in mediaIDs ?? [] { + data.append(Data.multipart(key: "media_ids[]", value: mediaID)) + } + for pollOption in pollOptions ?? [] { + data.append(Data.multipart(key: "poll[options][]", value: pollOption)) + } + pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + + data.append(Data.multipartEnd()) + return data } } diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift index fd9a9c8f4..f86a71c8e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MultipartFormValue.swift @@ -35,3 +35,12 @@ extension String: MultipartFormValue { var multipartContentType: String? { return nil } var multipartFilename: String? { return nil } } + + +extension Int: MultipartFormValue { + var multipartValue: Data { + return String(self).data(using: .utf8)! + } + var multipartContentType: String? { return nil } + var multipartFilename: String? { return nil } +} From 135e88c650e7c63fc4d0a5bd2a472bf66def2cdc Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 24 Mar 2021 15:46:40 +0800 Subject: [PATCH 140/400] feat: add poll option reorder supports --- ...lOptionAppendEntryCollectionViewCell.swift | 27 +++++++++++-- ...seStatusPollOptionCollectionViewCell.swift | 23 ++++++++++- .../Scene/Compose/ComposeViewController.swift | 31 ++++++++------ .../Compose/ComposeViewModel+Diffable.swift | 40 +++++++++---------- .../ComposeViewModel+PublishState.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 14 +++---- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift index 9479575b7..2c321f51f 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -13,11 +13,20 @@ protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: class { } final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionViewCell { - + let pollOptionView = PollOptionView() + let reorderBarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + weak var delegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? + override var isHighlighted: Bool { didSet { pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color @@ -25,7 +34,9 @@ final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionVi } } - weak var delegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate? + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return pollOptionView.frame.contains(point) + } override func prepareForReuse() { super.prepareForReuse() @@ -53,10 +64,18 @@ extension ComposeStatusPollOptionAppendEntryCollectionViewCell { NSLayoutConstraint.activate([ pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(reorderBarImageView) + NSLayoutConstraint.activate([ + reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin), + reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + pollOptionView.checkmarkImageView.isHidden = true pollOptionView.checkmarkBackgroundView.isHidden = true pollOptionView.optionPercentageLabel.isHidden = true @@ -68,6 +87,8 @@ extension ComposeStatusPollOptionAppendEntryCollectionViewCell { pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) singleTagGestureRecognizer.addTarget(self, action: #selector(ComposeStatusPollOptionAppendEntryCollectionViewCell.singleTagGestureRecognizerHandler(_:))) + + reorderBarImageView.isHidden = true } private func setupBorderColor() { diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 3d930682b..8935d804e 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -16,16 +16,29 @@ protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { final class ComposeStatusPollOptionCollectionViewCell: UICollectionViewCell { + static let reorderHandlerImageLeadingMargin: CGFloat = 11 + var disposeBag = Set() weak var delegate: ComposeStatusPollOptionCollectionViewCellDelegate? let pollOptionView = PollOptionView() + let reorderBarImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(systemName: "line.horizontal.3")?.withConfiguration(UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)).withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() let singleTagGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer private var pollOptionSubscription: AnyCancellable? let pollOption = PassthroughSubject() + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return pollOptionView.frame.contains(point) + } + override func prepareForReuse() { super.prepareForReuse() @@ -53,10 +66,18 @@ extension ComposeStatusPollOptionCollectionViewCell { NSLayoutConstraint.activate([ pollOptionView.topAnchor.constraint(equalTo: contentView.topAnchor), pollOptionView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - pollOptionView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), pollOptionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + reorderBarImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(reorderBarImageView) + NSLayoutConstraint.activate([ + reorderBarImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + reorderBarImageView.leadingAnchor.constraint(equalTo: pollOptionView.trailingAnchor, constant: ComposeStatusPollOptionCollectionViewCell.reorderHandlerImageLeadingMargin), + reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + pollOptionView.checkmarkImageView.isHidden = true pollOptionView.optionPercentageLabel.isHidden = true pollOptionView.optionTextField.text = nil diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 95398ca1b..19605ff38 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -150,9 +150,6 @@ extension ComposeViewController { ]) collectionView.delegate = self - // Note: do not allow reorder due to the images display order following the upload time - // let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) - // collectionView.addGestureRecognizer(longPressReorderGesture) viewModel.setupDiffableDataSource( for: collectionView, dependency: self, @@ -162,6 +159,8 @@ extension ComposeViewController { composeStatusNewPollOptionCollectionViewCellDelegate: self, composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) + let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) + collectionView.addGestureRecognizer(longPressReorderGesture) // respond scrollView overlap change view.layoutIfNeeded() @@ -389,13 +388,20 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } - /* Do not allow reorder image due to image display order following the update time + // seealso: ComposeViewModel.setupDiffableDataSource(…) @objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) { switch(sender.state) { case .began: - guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)) else { + guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), + let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else { break } + // check if pressing reorder bar no not + let locationInCell = sender.location(in: cell) + guard cell.reorderBarImageView.frame.contains(locationInCell) else { + return + } + collectionView.beginInteractiveMovementForItem(at: selectedIndexPath) case .changed: guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)), @@ -403,19 +409,20 @@ extension ComposeViewController { break } guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath), - case .attachment = item else { + case .pollOption = item else { collectionView.cancelInteractiveMovement() return } - collectionView.updateInteractiveMovementTargetPosition(sender.location(in: collectionView)) + var position = sender.location(in: collectionView) + position.x = collectionView.frame.width * 0.5 + collectionView.updateInteractiveMovementTargetPosition(position) case .ended: collectionView.endInteractiveMovement() default: collectionView.cancelInteractiveMovement() } } - */ } @@ -571,8 +578,8 @@ extension ComposeViewController: ComposeToolbarViewDelegate { viewModel.isPollComposing.value.toggle() // setup initial poll option if needs - if viewModel.isPollComposing.value, viewModel.pollAttributes.value.isEmpty { - viewModel.pollAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()] + if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty { + viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()] } if viewModel.isPollComposing.value { @@ -708,7 +715,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case let .pollOption(attribute) = item else { return } - var pollAttributes = viewModel.pollAttributes.value + var pollAttributes = viewModel.pollOptionAttributes.value guard let index = pollAttributes.firstIndex(of: attribute) else { return } // mark previous (fallback to next) item of removed middle poll option become first responder @@ -741,7 +748,7 @@ extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelega pollAttributes.remove(at: index) // update data source - viewModel.pollAttributes.value = pollAttributes + viewModel.pollOptionAttributes.value = pollAttributes } // handle keyboard return event for poll option input diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index bfca4a39f..3f9f3d3fa 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -31,26 +31,26 @@ extension ComposeViewModel { composeStatusPollExpiresOptionCollectionViewCellDelegate: composeStatusPollExpiresOptionCollectionViewCellDelegate ) - // Note: do not allow reorder due to the images display order following the upload time - // diffableDataSource.reorderingHandlers.canReorderItem = { item in - // switch item { - // case .attachment: return true - // default: return false - // } - // - // } - // diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in - // guard let self = self else { return } - // - // let items = transaction.finalSnapshot.itemIdentifiers - // var attachmentServices: [MastodonAttachmentService] = [] - // for item in items { - // guard case let .attachment(attachmentService) = item else { continue } - // attachmentServices.append(attachmentService) - // } - // self.attachmentServices.value = attachmentServices - // } - // + diffableDataSource.reorderingHandlers.canReorderItem = { item in + switch item { + case .pollOption: return true + default: return false + } + } + + // update reordered data source + diffableDataSource.reorderingHandlers.didReorder = { [weak self] transaction in + guard let self = self else { return } + + let items = transaction.finalSnapshot.itemIdentifiers + var pollOptionAttributes: [ComposeStatusItem.ComposePollOptionAttribute] = [] + for item in items { + guard case let .pollOption(attribute) = item else { continue } + pollOptionAttributes.append(attribute) + } + self.pollOptionAttributes.value = pollOptionAttributes + } + self.diffableDataSource = diffableDataSource var snapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 040ba26c7..85ad46684 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -55,7 +55,7 @@ extension ComposeViewModel.PublishState { } let pollOptions: [String]? = { guard viewModel.isPollComposing.value else { return nil } - return viewModel.pollAttributes.value.map { attribute in attribute.option.value } + return viewModel.pollOptionAttributes.value.map { attribute in attribute.option.value } }() let pollExpiresIn: Int? = { guard viewModel.isPollComposing.value else { return nil } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index e423312ef..80cd67695 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -52,7 +52,7 @@ final class ComposeViewModel { let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) // polls - let pollAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) + let pollOptionAttributes = CurrentValueSubject<[ComposeStatusItem.ComposePollOptionAttribute], Never>([]) let pollExpiresOptionAttribute = ComposeStatusItem.ComposePollExpiresOptionAttribute() init( @@ -105,7 +105,7 @@ final class ComposeViewModel { .map { services in services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } - let isPollAttributeAllValid = pollAttributes + let isPollAttributeAllValid = pollOptionAttributes .map { pollAttributes in pollAttributes.allSatisfy { attribute -> Bool in !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -177,7 +177,7 @@ final class ComposeViewModel { Publishers.CombineLatest3( attachmentServices.eraseToAnyPublisher(), isPollComposing.eraseToAnyPublisher(), - pollAttributes.eraseToAnyPublisher() + pollOptionAttributes.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices, isPollComposing, pollAttributes in @@ -240,7 +240,7 @@ final class ComposeViewModel { } .store(in: &disposeBag) - pollAttributes + pollOptionAttributes .sink { [weak self] pollAttributes in guard let self = self else { return } pollAttributes.forEach { $0.delegate = self } @@ -268,10 +268,10 @@ final class ComposeViewModel { extension ComposeViewModel { func createNewPollOptionIfPossible() { - guard pollAttributes.value.count < 4 else { return } + guard pollOptionAttributes.value.count < 4 else { return } let attribute = ComposeStatusItem.ComposePollOptionAttribute() - pollAttributes.value = pollAttributes.value + [attribute] + pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } } @@ -287,6 +287,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update - pollAttributes.value = pollAttributes.value + pollOptionAttributes.value = pollOptionAttributes.value } } From df66cc6b4aff6957cb700f66c52352669a35c78c Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 25 Mar 2021 15:56:17 +0800 Subject: [PATCH 141/400] feat: implement emoji picker --- Mastodon.xcodeproj/project.pbxproj | 64 +++++++++++----- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../Item/CustomEmojiPickerItem.swift | 36 +++++++++ .../Section/ComposeStatusSection.swift | 60 ++++++++++++++- .../Section/CustomEmojiPickerSection.swift | 60 +++++++++++++++ ...seStatusPollOptionCollectionViewCell.swift | 1 + ...jiPickerHeaderCollectionReusableView.swift | 42 +++++++++++ ...tomEmojiPickerItemCollectionViewCell.swift | 52 +++++++++++++ .../Scene/Compose/ComposeViewController.swift | 66 +++++++++++++++-- .../Compose/ComposeViewModel+Diffable.swift | 49 ++++++++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 9 ++- .../View/CustomEmojiPickerInputView.swift | 74 +++++++++++++++++++ .../CustomEmojiPickerInputViewModel.swift | 58 +++++++++++++++ 13 files changed, 547 insertions(+), 32 deletions(-) create mode 100644 Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift create mode 100644 Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift create mode 100644 Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift create mode 100644 Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift create mode 100644 Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6b719bc71..d934ae236 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */; }; DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */; }; DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */; }; + DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */; }; DB2B3ABC25E37E15007045F9 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */; }; DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; @@ -134,6 +135,11 @@ DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; + DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; }; + DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; }; + DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */; }; + DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */; }; + DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */; }; DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481AC25EE155900BEFB67 /* Poll.swift */; }; DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B225EE16D000BEFB67 /* PollOption.swift */; }; DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4481B825EE289600BEFB67 /* UITableView.swift */; }; @@ -166,8 +172,6 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; - DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; }; - DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB6672A225F9FDE500D60309 /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -245,6 +249,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; + DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; + DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -292,7 +298,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - DB6672A425F9FDE500D60309 /* TwitterTextEditor in Embed Frameworks */, + DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -416,6 +422,7 @@ DB1FD44F25F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+Diffable.swift"; sourceTree = ""; }; DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerItem.swift; sourceTree = ""; }; DB1FD45F25F278AF004CFCFC /* CategoryPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; + DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputViewModel.swift; sourceTree = ""; }; DB2B3ABD25E37E15007045F9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; @@ -436,6 +443,11 @@ DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; + DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = ""; }; + DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = ""; }; + DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItem.swift; sourceTree = ""; }; + DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerItemCollectionViewCell.swift; sourceTree = ""; }; + DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerHeaderCollectionReusableView.swift; sourceTree = ""; }; DB4481AC25EE155900BEFB67 /* Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; DB4481B225EE16D000BEFB67 /* PollOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOption.swift; sourceTree = ""; }; DB4481B825EE289600BEFB67 /* UITableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableView.swift; sourceTree = ""; }; @@ -555,7 +567,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB6672A325F9FDE500D60309 /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -565,6 +576,7 @@ DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, + DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -763,6 +775,7 @@ isa = PBXGroup; children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, + DB49A61925FF327D00B98345 /* EmojiService */, DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, @@ -770,7 +783,6 @@ 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, - DB49A61925FF327D00B98345 /* EmojiService */, ); path = Service; sourceTree = ""; @@ -830,6 +842,7 @@ DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, + DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, ); path = Section; sourceTree = ""; @@ -880,6 +893,7 @@ DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, + DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, ); path = Item; sourceTree = ""; @@ -1106,6 +1120,8 @@ DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */, DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */, DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, + DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */, + DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */, ); path = View; sourceTree = ""; @@ -1167,6 +1183,8 @@ DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, + DB447690260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift */, + DB447696260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -1466,8 +1484,8 @@ DB5086B725CC0D6400C2C187 /* Kingfisher */, 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, - DB6672A225F9FDE500D60309 /* TwitterTextEditor */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, + DBE64A8A260C49D200E6359A /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1597,8 +1615,8 @@ DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, - DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, + DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1797,6 +1815,7 @@ 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, + DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, @@ -1878,14 +1897,17 @@ 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, + DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, + DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, + DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, @@ -1919,6 +1941,7 @@ 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, @@ -1936,6 +1959,7 @@ 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, + DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, @@ -2557,14 +2581,6 @@ minimumVersion = 6.1.0; }; }; - DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/twitter/TwitterTextEditor.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; - }; - }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -2573,6 +2589,14 @@ minimumVersion = 1.4.1; }; }; + DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; + requirement = { + branch = "feature/input-view"; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -2615,16 +2639,16 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - DB6672A225F9FDE500D60309 /* TwitterTextEditor */ = { - isa = XCSwiftPackageProductDependency; - package = DB6672A125F9FDE500D60309 /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; - productName = TwitterTextEditor; - }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; + DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 696ac7070..4f2051528 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -102,11 +102,11 @@ }, { "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/twitter/TwitterTextEditor.git", + "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", "state": { - "branch": null, - "revision": "8aa914134c5b6aa46e862de63f239ec0e3b52a91", - "version": "1.0.0" + "branch": "feature/input-view", + "revision": "03e7b7497d424d96268f5bcca1f8e9955bb80fea", + "version": null } }, { diff --git a/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift b/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift new file mode 100644 index 000000000..52f522703 --- /dev/null +++ b/Mastodon/Diffiable/Item/CustomEmojiPickerItem.swift @@ -0,0 +1,36 @@ +// +// CustomEmojiPickerItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import Foundation +import MastodonSDK + +enum CustomEmojiPickerItem { + case emoji(attribute: CustomEmojiAttribute) +} + +extension CustomEmojiPickerItem: Equatable, Hashable { } + +extension CustomEmojiPickerItem { + final class CustomEmojiAttribute: Equatable, Hashable { + let id = UUID() + + let emoji: Mastodon.Entity.Emoji + + init(emoji: Mastodon.Entity.Emoji) { + self.emoji = emoji + } + + static func == (lhs: CustomEmojiPickerItem.CustomEmojiAttribute, rhs: CustomEmojiPickerItem.CustomEmojiAttribute) -> Bool { + return lhs.id == rhs.id && + lhs.emoji.shortcode == rhs.emoji.shortcode + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } +} diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 86bf9bd75..49f65a7c4 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -27,19 +27,19 @@ extension ComposeStatusSection { } extension ComposeStatusSection { - static func collectionViewDiffableDataSource( for collectionView: UICollectionView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, + customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak customEmojiPickerInputViewModel] collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell @@ -68,6 +68,7 @@ extension ComposeStatusSection { attribute.composeContent.value = text } .store(in: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) return cell case .attachment(let attachmentService): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell @@ -136,6 +137,7 @@ extension ComposeStatusSection { .assign(to: \.value, on: attribute.option) .store(in: &cell.disposeBag) cell.delegate = composeStatusPollOptionCollectionViewCellDelegate + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.pollOptionView.optionTextField, disposeBag: &cell.disposeBag) return cell case .pollOptionAppendEntry: let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionAppendEntryCollectionViewCell @@ -158,6 +160,7 @@ extension ComposeStatusSection { } extension ComposeStatusSection { + static func configure( cell: ComposeStatusContentCollectionViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute @@ -187,4 +190,57 @@ extension ComposeStatusSection { .assign(to: \.value, on: attribute.composeContent) .store(in: &cell.disposeBag) } + +} + +protocol CustomEmojiReplacableTextInput: AnyObject { + var inputView: UIView? { get set } + func reloadInputViews() + + // UIKeyInput + func insertText(_ text: String) + // UIResponder + var isFirstResponder: Bool { get } +} + +class CustomEmojiReplacableTextInputReference { + weak var value: CustomEmojiReplacableTextInput? + + init(value: CustomEmojiReplacableTextInput? = nil) { + self.value = value + } +} + +extension TextEditorView: CustomEmojiReplacableTextInput { + func insertText(_ text: String) { + try? updateByReplacing(range: selectedRange, with: text, selectedRange: nil) + } + + public override var isFirstResponder: Bool { + return isEditing + } + +} +extension UITextField: CustomEmojiReplacableTextInput { } +extension UITextView: CustomEmojiReplacableTextInput { } + +extension ComposeStatusSection { + + static func configureCustomEmojiPicker( + viewModel: CustomEmojiPickerInputViewModel?, + customEmojiReplacableTextInput: CustomEmojiReplacableTextInput, + disposeBag: inout Set + ) { + guard let viewModel = viewModel else { return } + viewModel.isCustomEmojiComposing + .receive(on: DispatchQueue.main) + .sink { [weak viewModel] isCustomEmojiComposing in + guard let viewModel = viewModel else { return } + customEmojiReplacableTextInput.inputView = isCustomEmojiComposing ? viewModel.customEmojiPickerInputView : nil + customEmojiReplacableTextInput.reloadInputViews() + viewModel.append(customEmojiReplacableTextInput: customEmojiReplacableTextInput) + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift new file mode 100644 index 000000000..2167d6f5c --- /dev/null +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -0,0 +1,60 @@ +// +// CustomEmojiPickerSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit +import Kingfisher + +enum CustomEmojiPickerSection: Equatable, Hashable { + case emoji(name: String) +} + +extension CustomEmojiPickerSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + switch item { + case .emoji(let attribute): + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell + let placeholder = UIImage.placeholder(size: CustomEmojiPickerItemCollectionViewCell.itemSize, color: .systemFill) + .af.imageRounded(withCornerRadius: 4) + cell.emojiImageView.kf.setImage( + with: URL(string: attribute.emoji.url), + placeholder: placeholder, + options: [ + .transition(.fade(0.2)) + ], + completionHandler: nil + ) + return cell + } + } + + dataSource.supplementaryViewProvider = { [weak dataSource] collectionView, kind, indexPath -> UICollectionReusableView? in + guard let dataSource = dataSource else { return nil } + let sections = dataSource.snapshot().sectionIdentifiers + guard indexPath.section < sections.count else { return nil } + let section = sections[indexPath.section] + + switch kind { + case String(describing: CustomEmojiPickerHeaderCollectionReusableView.self): + let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), for: indexPath) as! CustomEmojiPickerHeaderCollectionReusableView + switch section { + case .emoji(let name): + header.titlelabel.text = name + } + return header + default: + assertionFailure() + return nil + } + } + + return dataSource + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 8935d804e..712fea745 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -77,6 +77,7 @@ extension ComposeStatusPollOptionCollectionViewCell { reorderBarImageView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), reorderBarImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + reorderBarImageView.setContentCompressionResistancePriority(.defaultHigh + 10, for: .horizontal) pollOptionView.checkmarkImageView.isHidden = true pollOptionView.optionPercentageLabel.isHidden = true diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift new file mode 100644 index 000000000..61753a4c2 --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerHeaderCollectionReusableView.swift @@ -0,0 +1,42 @@ +// +// CustomEmojiPickerHeaderCollectionReusableView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerHeaderCollectionReusableView: UICollectionReusableView { + + let titlelabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 12, weight: .bold)) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerHeaderCollectionReusableView { + private func _init() { + titlelabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titlelabel) + NSLayoutConstraint.activate([ + titlelabel.topAnchor.constraint(equalTo: topAnchor, constant: 20), + titlelabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titlelabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + titlelabel.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift new file mode 100644 index 000000000..7acc49aeb --- /dev/null +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift @@ -0,0 +1,52 @@ +// +// CustomEmojiPickerItemCollectionViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerItemCollectionViewCell: UICollectionViewCell { + + static let itemSize = CGSize(width: 44, height: 44) + + let emojiImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.layer.masksToBounds = true + return imageView + }() + + override var isHighlighted: Bool { + didSet { + emojiImageView.alpha = isHighlighted ? 0.5 : 1.0 + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerItemCollectionViewCell { + + private func _init() { + emojiImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(emojiImageView) + NSLayoutConstraint.activate([ + emojiImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + emojiImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + emojiImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + emojiImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 19605ff38..abb486112 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -52,9 +52,25 @@ final class ComposeViewController: UIViewController, NeedsDependency { return collectionView }() + var systemKeyboardHeight: CGFloat = .zero { + didSet { + // note: some system AutoLayout warning here + customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300 + } + } + + // CustomEmojiPickerView + let customEmojiPickerInputView: CustomEmojiPickerInputView = { + let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard) + return view + }() + let composeToolbarView: ComposeToolbarView = { let composeToolbarView = ComposeToolbarView() - composeToolbarView.backgroundColor = .secondarySystemBackground + let text = UITextView() + let inputView = UIInputView(frame: .init(x: 0, y: 0, width: 40, height: 40), inputViewStyle: .keyboard) + text.inputAccessoryView = inputView + composeToolbarView.backgroundColor = inputView.backgroundColor return composeToolbarView }() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! @@ -153,6 +169,7 @@ extension ComposeViewController { viewModel.setupDiffableDataSource( for: collectionView, dependency: self, + customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: self, composeStatusAttachmentTableViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, @@ -162,15 +179,23 @@ extension ComposeViewController { let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:))) collectionView.addGestureRecognizer(longPressReorderGesture) + customEmojiPickerInputView.collectionView.delegate = self + viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView + viewModel.setupCustomEmojiPickerDiffableDataSource( + for: customEmojiPickerInputView.collectionView, + dependency: self + ) + // respond scrollView overlap change view.layoutIfNeeded() // update layout when keyboard show/dismiss - Publishers.CombineLatest3( + Publishers.CombineLatest4( KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), KeyboardResponderService.shared.state.eraseToAnyPublisher(), - KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(), + viewModel.isCustomEmojiComposing.eraseToAnyPublisher() ) - .sink(receiveValue: { [weak self] isShow, state, endFrame in + .sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in guard let self = self else { return } guard isShow, state == .dock else { @@ -182,8 +207,9 @@ extension ComposeViewController { } return } - // isShow AND dock state + self.systemKeyboardHeight = endFrame.height + let contentFrame = self.view.convert(self.collectionView.frame, to: nil) let padding = contentFrame.maxY - endFrame.minY guard padding > 0 else { @@ -593,6 +619,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) { + viewModel.isCustomEmojiComposing.value.toggle() } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { @@ -606,6 +633,35 @@ extension ComposeViewController: ComposeToolbarViewDelegate { // MARK: - UITableViewDelegate extension ComposeViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + + if collectionView === customEmojiPickerInputView.collectionView { + guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return } + let item = diffableDataSource.itemIdentifier(for: indexPath) + guard case let .emoji(attribute) = item else { return } + let emoji = attribute.emoji + let textEditorView = self.textEditorView() + + // retrive active text input and insert emoji + // the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue + let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ") + + // workaround: non-user interactive change do not trigger value update event + if reference?.value === textEditorView { + viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text + // update text storage + textEditorView?.setNeedsUpdateTextAttributes() + // collection self-size + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.collectionView.collectionViewLayout.invalidateLayout() + } + } + } else { + // do nothing + } + } } // MARK: - UIAdaptivePresentationControllerDelegate diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 3f9f3d3fa..46bdbac1b 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -6,13 +6,16 @@ // import UIKit +import Combine import TwitterTextEditor +import MastodonSDK extension ComposeViewModel { func setupDiffableDataSource( for collectionView: UICollectionView, dependency: NeedsDependency, + customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, @@ -24,6 +27,7 @@ extension ComposeViewModel { dependency: dependency, managedObjectContext: context.managedObjectContext, composeKind: composeKind, + customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, composeStatusPollOptionCollectionViewCellDelegate: composeStatusPollOptionCollectionViewCellDelegate, @@ -65,4 +69,49 @@ extension ComposeViewModel { diffableDataSource.apply(snapshot, animatingDifferences: false) } + func setupCustomEmojiPickerDiffableDataSource( + for collectionView: UICollectionView, + dependency: NeedsDependency + ) { + let diffableDataSource = CustomEmojiPickerSection.collectionViewDiffableDataSource( + for: collectionView, + dependency: dependency + ) + self.customEmojiPickerDiffableDataSource = diffableDataSource + + customEmojiViewModel + .sink { [weak self, weak diffableDataSource] customEmojiViewModel in + guard let self = self else { return } + guard let diffableDataSource = diffableDataSource else { return } + guard let customEmojiViewModel = customEmojiViewModel else { + self.customEmojiViewModelSubscription = nil + let snapshot = NSDiffableDataSourceSnapshot() + diffableDataSource.apply(snapshot) + return + } + + self.customEmojiViewModelSubscription = customEmojiViewModel.emojis + .receive(on: DispatchQueue.main) + .sink { [weak self, weak diffableDataSource] emojis in + guard let _ = self else { return } + guard let diffableDataSource = diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + let customEmojiSection = CustomEmojiPickerSection.emoji(name: customEmojiViewModel.domain.uppercased()) + snapshot.appendSections([customEmojiSection]) + let items: [CustomEmojiPickerItem] = { + var items = [CustomEmojiPickerItem]() + for emoji in emojis where emoji.visibleInPicker { + let attribute = CustomEmojiPickerItem.CustomEmojiAttribute(emoji: emoji) + let item = CustomEmojiPickerItem.emoji(attribute: attribute) + items.append(item) + } + return items + }() + snapshot.appendItems(items, toSection: customEmojiSection) + diffableDataSource.apply(snapshot) + } + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 80cd67695..095d37899 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -20,12 +20,13 @@ final class ComposeViewModel { let composeKind: ComposeStatusSection.ComposeKind let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let isPollComposing = CurrentValueSubject(false) + let isCustomEmojiComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject // output - //var diffableDataSource: UITableViewDiffableDataSource! var diffableDataSource: UICollectionViewDiffableDataSource! + var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource! private(set) lazy var publishStateMachine: GKStateMachine = { // exclude timeline middle fetcher state let stateMachine = GKStateMachine(states: [ @@ -46,7 +47,9 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) // custom emojis + var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) + let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) @@ -69,6 +72,10 @@ final class ComposeViewModel { self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init + isCustomEmojiComposing + .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) + .store(in: &disposeBag) + // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift new file mode 100644 index 000000000..87b1ee481 --- /dev/null +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift @@ -0,0 +1,74 @@ +// +// CustomEmojiPickerInputView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-24. +// + +import UIKit + +final class CustomEmojiPickerInputView: UIInputView { + + private(set) lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout()) + collectionView.register(CustomEmojiPickerItemCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self)) + collectionView.register(CustomEmojiPickerHeaderCollectionReusableView.self, forSupplementaryViewOfKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), withReuseIdentifier: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self)) + collectionView.backgroundColor = .clear + return collectionView + }() + + override init(frame: CGRect, inputViewStyle: UIInputView.Style) { + super.init(frame: frame, inputViewStyle: inputViewStyle) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension CustomEmojiPickerInputView { + private func _init() { + allowsSelfSizing = true + + collectionView.translatesAutoresizingMaskIntoConstraints = false + addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: topAnchor), + collectionView.leadingAnchor.constraint(equalTo: leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } +} + +extension CustomEmojiPickerInputView { + func createLayout() -> UICollectionViewLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.width), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(4), top: .flexible(4), trailing: .flexible(0), bottom: .flexible(0)) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(CustomEmojiPickerItemCollectionViewCell.itemSize.height)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 5 + section.contentInsetsReference = .readableContent + section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0) + + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(44)), + elementKind: String(describing: CustomEmojiPickerHeaderCollectionReusableView.self), + alignment: .top) + // sectionHeader.pinToVisibleBounds = true + sectionHeader.zIndex = 2 + section.boundarySupplementaryItems = [sectionHeader] + + let layout = UICollectionViewCompositionalLayout(section: section) + return layout + } +} diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift new file mode 100644 index 000000000..02a45d922 --- /dev/null +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputViewModel.swift @@ -0,0 +1,58 @@ +// +// CustomEmojiPickerInputViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-25. +// + +import UIKit +import Combine + +final class CustomEmojiPickerInputViewModel { + + var disposeBag = Set() + + private var customEmojiReplacableTextInputReferences: [CustomEmojiReplacableTextInputReference] = [] + + // input + weak var customEmojiPickerInputView: CustomEmojiPickerInputView? + + // output + let isCustomEmojiComposing = CurrentValueSubject(false) + +} + +extension CustomEmojiPickerInputViewModel { + + private func removeEmptyReferences() { + customEmojiReplacableTextInputReferences.removeAll(where: { element in + element.value == nil + }) + } + + func append(customEmojiReplacableTextInput textInput: CustomEmojiReplacableTextInput) { + removeEmptyReferences() + + let isContains = customEmojiReplacableTextInputReferences.contains(where: { element in + element.value === textInput + }) + guard !isContains else { + return + } + customEmojiReplacableTextInputReferences.append(CustomEmojiReplacableTextInputReference(value: textInput)) + } + + func insertText(_ text: String) -> CustomEmojiReplacableTextInputReference? { + removeEmptyReferences() + + for reference in customEmojiReplacableTextInputReferences { + guard reference.value?.isFirstResponder == true else { continue } + reference.value?.insertText(text) + return reference + } + + return nil + } + +} + From 610ee36835e09e5d2352dfa1b8bdcc2d22b51a3e Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 25 Mar 2021 18:17:05 +0800 Subject: [PATCH 142/400] feat: add content warning editor for status compose scene --- Localization/app.json | 3 + Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Item/ComposeStatusItem.swift | 9 +- .../Section/ComposeStatusSection.swift | 31 +++++ Mastodon/Generated/Strings.swift | 4 + .../Resources/en.lproj/Localizable.strings | 1 + ...mposeStatusContentCollectionViewCell.swift | 49 ++++++-- .../Scene/Compose/ComposeViewController.swift | 1 + .../ComposeViewModel+PublishState.swift | 12 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 5 + .../View/StatusContentWarningEditorView.swift | 114 ++++++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 3 +- .../API/Mastodon+API+Statuses.swift | 16 ++- 13 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift diff --git a/Localization/app.json b/Localization/app.json index 3a3db1300..10c672a73 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -216,6 +216,9 @@ "one_day": "1 Day", "three_days": "3 Days", "seven_days": "7 Days" + }, + "content_warning": { + "placeholder": "Write an accurate warning here..." } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d934ae236..3e3fc4680 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -245,6 +245,7 @@ DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; + DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -553,6 +554,7 @@ DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -1122,6 +1124,7 @@ DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */, DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */, DB221B15260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift */, + DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */, ); path = View; sourceTree = ""; @@ -1904,6 +1907,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, + DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 86e8c6228..88bff36c3 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -32,11 +32,16 @@ extension ComposeStatusItem { let username = CurrentValueSubject(nil) let composeContent = CurrentValueSubject(nil) + let isContentWarningComposing = CurrentValueSubject(false) + let contentWarningContent = CurrentValueSubject("") + static func == (lhs: ComposeStatusAttribute, rhs: ComposeStatusAttribute) -> Bool { return lhs.avatarURL.value == rhs.avatarURL.value && lhs.displayName.value == rhs.displayName.value && - lhs.username.value == rhs.username.value && - lhs.composeContent.value == rhs.composeContent.value + lhs.username.value == rhs.username.value && + lhs.composeContent.value == rhs.composeContent.value && + lhs.isContentWarningComposing.value == rhs.isContentWarningComposing.value && + lhs.contentWarningContent.value == rhs.contentWarningContent.value } func hash(into hasher: inout Hasher) { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 49f65a7c4..4a0e90d76 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -68,6 +68,37 @@ extension ComposeStatusSection { attribute.composeContent.value = text } .store(in: &cell.disposeBag) + attribute.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { isContentWarningComposing in + // self size input cell + collectionView.collectionViewLayout.invalidateLayout() + cell.statusContentWarningEditorView.containerView.isHidden = !isContentWarningComposing + cell.statusContentWarningEditorView.alpha = 0 + UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { + cell.statusContentWarningEditorView.alpha = 1 + } completion: { _ in + if isContentWarningComposing { + cell.statusContentWarningEditorView.textView.becomeFirstResponder() + } + // do nothing + } + // restore responder if needs + if cell.statusContentWarningEditorView.textView.isFirstResponder { + cell.textEditorView.isEditing = true + } + } + .store(in: &cell.disposeBag) + cell.contentWarningContent + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { text in + // self size input cell + collectionView.collectionViewLayout.invalidateLayout() + // bind input data + attribute.contentWarningContent.value = text + } + .store(in: &cell.disposeBag) ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) return cell case .attachment(let attachmentService): diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 86c3bf13e..8e7da586d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -162,6 +162,10 @@ internal enum L10n { /// video internal static let video = L10n.tr("Localizable", "Scene.Compose.Attachment.Video") } + internal enum ContentWarning { + /// Write an accurate warning here... + internal static let placeholder = L10n.tr("Localizable", "Scene.Compose.ContentWarning.Placeholder") + } internal enum MediaSelection { /// Browse internal static let browse = L10n.tr("Localizable", "Scene.Compose.MediaSelection.Browse") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index dd34cbfe1..860bf2db5 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -47,6 +47,7 @@ uploaded to Mastodon."; "Scene.Compose.Attachment.Video" = "video"; "Scene.Compose.ComposeAction" = "Publish"; "Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; "Scene.Compose.MediaSelection.Browse" = "Browse"; "Scene.Compose.MediaSelection.Camera" = "Take Photo"; "Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 1537215d0..f1fe6b541 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-3-11. // +import os.log import UIKit import Combine import TwitterTextEditor @@ -15,6 +16,8 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { let statusView = StatusView() + let statusContentWarningEditorView = StatusContentWarningEditorView() + let textEditorView: TextEditorView = { let textEditorView = TextEditorView() textEditorView.font = .preferredFont(forTextStyle: .body) @@ -25,7 +28,9 @@ final class ComposeStatusContentCollectionViewCell: UICollectionViewCell { return textEditorView }() + // output let composeContent = PassthroughSubject() + let contentWarningContent = PassthroughSubject() override init(frame: CGRect) { super.init(frame: frame) @@ -45,10 +50,20 @@ extension ComposeStatusContentCollectionViewCell { // selectionStyle = .none preservesSuperviewLayoutMargins = true + statusContentWarningEditorView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusContentWarningEditorView) + NSLayoutConstraint.activate([ + statusContentWarningEditorView.topAnchor.constraint(equalTo: contentView.topAnchor), + statusContentWarningEditorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + statusContentWarningEditorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + statusContentWarningEditorView.preservesSuperviewLayoutMargins = true + statusContentWarningEditorView.containerBackgroundView.isHidden = false + statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.topAnchor.constraint(equalTo: statusContentWarningEditorView.bottomAnchor, constant: 20), statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), statusView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), ]) @@ -70,23 +85,39 @@ extension ComposeStatusContentCollectionViewCell { textEditorView.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), ]) textEditorView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - - // TODO: - + + statusContentWarningEditorView.textView.delegate = self textEditorView.changeObserver = self - } - - override func didMoveToWindow() { - super.didMoveToWindow() + statusContentWarningEditorView.containerView.isHidden = true } } -// MARK: - UITextViewDelegate +// MARK: - TextEditorViewChangeObserver extension ComposeStatusContentCollectionViewCell: TextEditorViewChangeObserver { func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) guard changeResult.isTextChanged else { return } composeContent.send(textEditorView.text) } } + +// MARK: - UITextViewDelegate +extension ComposeStatusContentCollectionViewCell: UITextViewDelegate { + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + // disable input line break + guard text != "\n" else { return false } + return true + } + + func textViewDidChange(_ textView: UITextView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textView.text) + guard textView === statusContentWarningEditorView.textView else { return } + // replace line break with space + textView.text = textView.text.replacingOccurrences(of: "\n", with: " ") + contentWarningContent.send(textView.text) + } + +} diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index abb486112..7e1dc07b3 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -623,6 +623,7 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) { + viewModel.isContentWarningComposing.value.toggle() } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) { diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 85ad46684..222ac938a 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -61,6 +61,14 @@ extension ComposeViewModel.PublishState { guard viewModel.isPollComposing.value else { return nil } return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds }() + let sensitive: Bool = viewModel.isContentWarningComposing.value + let spoilerText: String? = { + let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + return nil + } + return text + }() let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { var subscriptions: [AnyPublisher, Error>] = [] @@ -92,7 +100,9 @@ extension ComposeViewModel.PublishState { status: viewModel.composeStatusAttribute.composeContent.value, mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, pollOptions: pollOptions, - pollExpiresIn: pollExpiresIn + pollExpiresIn: pollExpiresIn, + sensitive: sensitive, + spoilerText: spoilerText ) return viewModel.context.apiService.publishStatus( domain: domain, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 095d37899..c2e357d01 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -21,6 +21,7 @@ final class ComposeViewModel { let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() let isPollComposing = CurrentValueSubject(false) let isCustomEmojiComposing = CurrentValueSubject(false) + let isContentWarningComposing = CurrentValueSubject(false) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject @@ -76,6 +77,10 @@ final class ComposeViewModel { .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) + isContentWarningComposing + .assign(to: \.value, on: composeStatusAttribute.isContentWarningComposing) + .store(in: &disposeBag) + // bind active authentication context.authenticationService.activeMastodonAuthentication .assign(to: \.value, on: activeAuthentication) diff --git a/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift new file mode 100644 index 000000000..510edd464 --- /dev/null +++ b/Mastodon/Scene/Compose/View/StatusContentWarningEditorView.swift @@ -0,0 +1,114 @@ +// +// StatusContentWarningEditorView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-25. +// + +import UIKit + +final class StatusContentWarningEditorView: UIView { + + let containerView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + return view + }() + + // due to section following readable inset. We overlap the bleeding to make backgorund fill + // default hidden + let containerBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + view.isHidden = true + return view + }() + + let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = UIImage(systemName: "exclamationmark.shield")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 30, weight: .regular)).withRenderingMode(.alwaysTemplate) + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.contentMode = .center + return imageView + }() + + let textView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.isScrollEnabled = false + textView.placeholder = L10n.Scene.Compose.ContentWarning.placeholder + textView.backgroundColor = .clear + return textView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension StatusContentWarningEditorView { + private func _init() { + let contentWarningStackView = UIStackView() + contentWarningStackView.axis = .horizontal + contentWarningStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentWarningStackView) + NSLayoutConstraint.activate([ + contentWarningStackView.topAnchor.constraint(equalTo: topAnchor), + contentWarningStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentWarningStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentWarningStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + contentWarningStackView.addArrangedSubview(containerView) + + containerBackgroundView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(containerBackgroundView) + NSLayoutConstraint.activate([ + containerBackgroundView.topAnchor.constraint(equalTo: containerView.topAnchor), + containerBackgroundView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: -1024), + containerBackgroundView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 1024), + containerBackgroundView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + iconImageView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(iconImageView) + NSLayoutConstraint.activate([ + iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + iconImageView.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: StatusView.avatarImageSize.width).priority(.defaultHigh), // center alignment to avatar + ]) + iconImageView.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + textView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(textView) + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 6), + textView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: StatusView.avatarToLabelSpacing - 4), // align to name label. minus magic 4pt to remove addtion inset + textView.trailingAnchor.constraint(equalTo: containerView.readableContentGuide.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 6), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct StatusContentWarningEditorView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + StatusContentWarningEditorView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 4c03d1baa..6d7800b04 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -23,6 +23,7 @@ final class StatusView: UIView { static let avatarImageSize = CGSize(width: 42, height: 42) static let avatarImageCornerRadius: CGFloat = 4 + static let avatarToLabelSpacing: CGFloat = 5 static let contentWarningBlurRadius: CGFloat = 12 static let boostIconImage: UIImage = { @@ -249,7 +250,7 @@ extension StatusView { let authorContainerStackView = UIStackView() containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal - authorContainerStackView.spacing = 5 + authorContainerStackView.spacing = StatusView.avatarToLabelSpacing // avatar avatarView.translatesAutoresizingMaskIntoConstraints = false diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index e4bff7d54..d1fb95f40 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -98,12 +98,24 @@ extension Mastodon.API.Statuses { public let mediaIDs: [String]? public let pollOptions: [String]? public let pollExpiresIn: Int? + public let sensitive: Bool? + public let spoilerText: String? - public init(status: String?, mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?) { + public init( + status: String?, + mediaIDs: [String]?, + pollOptions: [String]?, + pollExpiresIn: Int?, + sensitive: Bool?, + spoilerText: String? + ) { self.status = status self.mediaIDs = mediaIDs self.pollOptions = pollOptions self.pollExpiresIn = pollExpiresIn + self.sensitive = sensitive + self.spoilerText = spoilerText + } var contentType: String? { @@ -121,6 +133,8 @@ extension Mastodon.API.Statuses { data.append(Data.multipart(key: "poll[options][]", value: pollOption)) } pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } + spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } data.append(Data.multipartEnd()) return data From 00e7450bcc7bd0ac7ad3ee0804cc11652bae04fd Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 25 Mar 2021 19:34:30 +0800 Subject: [PATCH 143/400] feat: add status visibility selector for status compose scene --- Localization/app.json | 6 ++ .../Section/ComposeStatusSection.swift | 2 + Mastodon/Generated/Strings.swift | 10 ++++ .../Resources/en.lproj/Localizable.strings | 4 ++ .../Scene/Compose/ComposeViewController.swift | 15 ++++- .../ComposeViewModel+PublishState.swift | 4 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 + .../Compose/View/ComposeToolbarView.swift | 59 ++++++++++++++++--- .../API/Mastodon+API+Statuses.swift | 6 +- 9 files changed, 94 insertions(+), 14 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 10c672a73..c0a305d96 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -219,6 +219,12 @@ }, "content_warning": { "placeholder": "Write an accurate warning here..." + }, + "visibility": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Followers only", + "direct": "Only people I mention" } } } diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 4a0e90d76..85af26678 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -100,6 +100,8 @@ extension ComposeStatusSection { } .store(in: &cell.disposeBag) ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.textEditorView, disposeBag: &cell.disposeBag) + ComposeStatusSection.configureCustomEmojiPicker(viewModel: customEmojiPickerInputViewModel, customEmojiReplacableTextInput: cell.statusContentWarningEditorView.textView, disposeBag: &cell.disposeBag) + return cell case .attachment(let attachmentService): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self), for: indexPath) as! ComposeStatusAttachmentCollectionViewCell diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8e7da586d..a875994ea 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -198,6 +198,16 @@ internal enum L10n { /// New Reply internal static let newReply = L10n.tr("Localizable", "Scene.Compose.Title.NewReply") } + internal enum Visibility { + /// Only people I mention + internal static let direct = L10n.tr("Localizable", "Scene.Compose.Visibility.Direct") + /// Followers only + internal static let `private` = L10n.tr("Localizable", "Scene.Compose.Visibility.Private") + /// Public + internal static let `public` = L10n.tr("Localizable", "Scene.Compose.Visibility.Public") + /// Unlisted + internal static let unlisted = L10n.tr("Localizable", "Scene.Compose.Visibility.Unlisted") + } } internal enum ConfirmEmail { /// We just sent an email to %@,\ntap the link to confirm your account. diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 860bf2db5..d2ebb4071 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -60,6 +60,10 @@ uploaded to Mastodon."; "Scene.Compose.Poll.ThreeDays" = "3 Days"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Visibility.Direct" = "Only people I mention"; +"Scene.Compose.Visibility.Private" = "Followers only"; +"Scene.Compose.Visibility.Public" = "Public"; +"Scene.Compose.Visibility.Unlisted" = "Unlisted"; "Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; "Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; "Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 7e1dc07b3..1de686201 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -270,6 +270,14 @@ extension ComposeViewController { self.resetImagePicker() } .store(in: &disposeBag) + + viewModel.selectedStatusVisibility + .receive(on: DispatchQueue.main) + .sink { [weak self] type in + guard let self = self else { return } + self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -589,8 +597,8 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) { - switch mediaSelectionType { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { + switch type { case .photoLibrary: present(imagePicker, animated: true, completion: nil) case .camera: @@ -626,7 +634,8 @@ extension ComposeViewController: ComposeToolbarViewDelegate { viewModel.isContentWarningComposing.value.toggle() } - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) { + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { + viewModel.selectedStatusVisibility.value = type } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 222ac938a..d5047cc9f 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -69,6 +69,7 @@ extension ComposeViewModel.PublishState { } return text }() + let visibility = viewModel.selectedStatusVisibility.value.visibility let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { var subscriptions: [AnyPublisher, Error>] = [] @@ -102,7 +103,8 @@ extension ComposeViewModel.PublishState { pollOptions: pollOptions, pollExpiresIn: pollExpiresIn, sensitive: sensitive, - spoilerText: spoilerText + spoilerText: spoilerText, + visibility: visibility ) return viewModel.context.apiService.publishStatus( domain: domain, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index c2e357d01..07570d796 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -10,6 +10,7 @@ import Combine import CoreData import CoreDataStack import GameplayKit +import MastodonSDK final class ComposeViewModel { @@ -22,6 +23,7 @@ final class ComposeViewModel { let isPollComposing = CurrentValueSubject(false) let isCustomEmojiComposing = CurrentValueSubject(false) let isContentWarningComposing = CurrentValueSubject(false) + let selectedStatusVisibility = CurrentValueSubject(.public) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index b109faf3e..8ba879c02 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -7,13 +7,14 @@ import os.log import UIKit +import MastodonSDK protocol ComposeToolbarViewDelegate: class { - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType: ComposeToolbarView.MediaSelectionType) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) - func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton) + func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) } final class ComposeToolbarView: UIView { @@ -106,7 +107,8 @@ extension ComposeToolbarView { pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) - visibilityButton.addTarget(self, action: #selector(ComposeToolbarView.visibilityButtonDidPressed(_:)), for: .touchUpInside) + visibilityButton.menu = createVisibilityContextMenu() + visibilityButton.showsMenuAsPrimaryAction = true } } @@ -116,6 +118,40 @@ extension ComposeToolbarView { case photoLibrary case browse } + + enum VisibilitySelectionType: String, CaseIterable { + case `public` + case unlisted + case `private` + case direct + + var title: String { + switch self { + case .public: return L10n.Scene.Compose.Visibility.public + case .unlisted: return L10n.Scene.Compose.Visibility.unlisted + case .private: return L10n.Scene.Compose.Visibility.private + case .direct: return L10n.Scene.Compose.Visibility.direct + } + } + + var image: UIImage { + switch self { + case .public: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! + case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! + case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! + } + } + + var visibility: Mastodon.Entity.Status.Visibility { + switch self { + case .public: return .public + case .unlisted: return .unlisted + case .private: return .private + case .direct: return .direct + } + } + } } extension ComposeToolbarView { @@ -154,9 +190,19 @@ extension ComposeToolbarView { return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) } + private func createVisibilityContextMenu() -> UIMenu { + let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in + UIAction(title: type.title, image: type.image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue) + self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type) + } + } + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + } - extension ComposeToolbarView { @objc private func pollButtonDidPressed(_ sender: UIButton) { @@ -174,11 +220,6 @@ extension ComposeToolbarView { delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender) } - @objc private func visibilityButtonDidPressed(_ sender: UIButton) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.composeToolbarView(self, visibilityButtonDidPressed: sender) - } - } #if canImport(SwiftUI) && DEBUG diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index d1fb95f40..da54c9344 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -100,6 +100,7 @@ extension Mastodon.API.Statuses { public let pollExpiresIn: Int? public let sensitive: Bool? public let spoilerText: String? + public let visibility: Mastodon.Entity.Status.Visibility? public init( status: String?, @@ -107,7 +108,8 @@ extension Mastodon.API.Statuses { pollOptions: [String]?, pollExpiresIn: Int?, sensitive: Bool?, - spoilerText: String? + spoilerText: String?, + visibility: Mastodon.Entity.Status.Visibility? ) { self.status = status self.mediaIDs = mediaIDs @@ -115,6 +117,7 @@ extension Mastodon.API.Statuses { self.pollExpiresIn = pollExpiresIn self.sensitive = sensitive self.spoilerText = spoilerText + self.visibility = visibility } @@ -135,6 +138,7 @@ extension Mastodon.API.Statuses { pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } + visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) } data.append(Data.multipartEnd()) return data From 59889cd683aa95f29f5c0c0857265bdaa35a43e3 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 26 Mar 2021 14:50:23 +0800 Subject: [PATCH 144/400] fix: compose scene leading issue --- Mastodon/Diffiable/Section/ComposeStatusSection.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 85af26678..81efbee18 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -39,7 +39,14 @@ extension ComposeStatusSection { composeStatusNewPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak customEmojiPickerInputViewModel] collectionView, indexPath, item -> UICollectionViewCell? in + UICollectionViewDiffableDataSource(collectionView: collectionView) { [ + weak customEmojiPickerInputViewModel, + weak textEditorViewTextAttributesDelegate, + weak composeStatusAttachmentTableViewCellDelegate, + weak composeStatusPollOptionCollectionViewCellDelegate, + weak composeStatusNewPollOptionCollectionViewCellDelegate, + weak composeStatusPollExpiresOptionCollectionViewCellDelegate + ] collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell @@ -213,7 +220,7 @@ extension ComposeStatusSection { .receive(on: DispatchQueue.main) .sink { displayName, username in cell.statusView.nameLabel.text = displayName - cell.statusView.usernameLabel.text = username + cell.statusView.usernameLabel.text = username.flatMap { "@" + $0 } ?? " " } .store(in: &cell.disposeBag) From 87a6a4df772fc23a0782d40539a38fe1887c67b1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 26 Mar 2021 19:16:19 +0800 Subject: [PATCH 145/400] feat: add counter and emoji picker activity indicator --- Mastodon.xcodeproj/project.pbxproj | 2 +- .../Section/ComposeStatusSection.swift | 7 - Mastodon/Generated/Assets.swift | 2 +- .../Contents.json | 0 ...seStatusPollOptionCollectionViewCell.swift | 7 + .../Scene/Compose/ComposeViewController.swift | 131 +++++++++++++++--- .../Compose/ComposeViewModel+Diffable.swift | 1 - Mastodon/Scene/Compose/ComposeViewModel.swift | 33 ++++- .../Compose/View/ComposeToolbarView.swift | 18 +++ .../View/CustomEmojiPickerInputView.swift | 12 ++ .../HomeTimelineNavigationBarView.swift | 2 +- .../MastodonRegisterViewController.swift | 8 +- .../Register/MastodonRegisterViewModel.swift | 4 +- 13 files changed, 190 insertions(+), 37 deletions(-) rename Mastodon/Resources/Assets.xcassets/Colors/{lightDangerRed.colorset => danger.colorset}/Contents.json (100%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 3e3fc4680..aff684185 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -1171,8 +1171,8 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, ); path = Compose; sourceTree = ""; diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 81efbee18..222c40246 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -85,15 +85,8 @@ extension ComposeStatusSection { UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { cell.statusContentWarningEditorView.alpha = 1 } completion: { _ in - if isContentWarningComposing { - cell.statusContentWarningEditorView.textView.becomeFirstResponder() - } // do nothing } - // restore responder if needs - if cell.statusContentWarningEditorView.textView.isFirstResponder { - cell.textEditorView.isEditing = true - } } .store(in: &cell.disposeBag) cell.contentWarningContent diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 366c70649..8bf3b168b 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -75,10 +75,10 @@ internal enum Asset { internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault") internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled") internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive") + internal static let danger = ColorAsset(name: "Colors/danger") internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") - internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed") internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray") internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled") internal static let lightInactive = ColorAsset(name: "Colors/lightInactive") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 712fea745..8846e56ed 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -10,6 +10,7 @@ import UIKit import Combine protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) } @@ -132,6 +133,12 @@ extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextF // MARK: - UITextFieldDelegate extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusPollOptionCollectionViewCell(self, textFieldDidBeginEditing: textField) + } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) if textField === pollOptionView.optionTextField { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 1de686201..5e3a6a8a9 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -10,6 +10,7 @@ import UIKit import Combine import PhotosUI import Kingfisher +import MastodonSDK import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { @@ -102,14 +103,18 @@ final class ComposeViewController: UIViewController, NeedsDependency { return documentPickerController }() + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ComposeViewController { private static func createLayout() -> UICollectionViewLayout { - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsetsReference = .readableContent // section.interGroupSpacing = 10 @@ -232,22 +237,61 @@ extension ComposeViewController { }) .store(in: &disposeBag) + // bind publish bar button state viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) + // bind media button toolbar state viewModel.isMediaToolbarButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) .store(in: &disposeBag) + // bind poll button toolbar state viewModel.isPollToolbarButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeToolbarView.pollButton) .store(in: &disposeBag) - // bind custom emojis + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) + + // bind visibility toolbar UI + viewModel.selectedStatusVisibility + .receive(on: DispatchQueue.main) + .sink { [weak self] type in + guard let self = self else { return } + self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) + } + .store(in: &disposeBag) + + viewModel.characterCount + .receive(on: DispatchQueue.main) + .sink { [weak self] characterCount in + guard let self = self else { return } + let count = ComposeViewModel.composeContentLimit - characterCount + self.composeToolbarView.characterCountLabel.text = "\(count)" + switch count { + case _ where count < 0: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color + default: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &disposeBag) + + // bind text editor for custom emojis update event viewModel.customEmojiViewModel .compactMap { $0?.emojis } .switchToLatest() @@ -261,22 +305,24 @@ extension ComposeViewController { }) .store(in: &disposeBag) - // bind image picker toolbar state - viewModel.attachmentServices + // bind custom emoji picker UI + viewModel.customEmojiViewModel .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices in - guard let self = self else { return } - self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 - self.resetImagePicker() + .map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in + guard let viewModel = viewModel else { + return Just([]).eraseToAnyPublisher() + } + return viewModel.emojis.eraseToAnyPublisher() } - .store(in: &disposeBag) - - viewModel.selectedStatusVisibility - .receive(on: DispatchQueue.main) - .sink { [weak self] type in + .switchToLatest() + .sink(receiveValue: { [weak self] emojis in guard let self = self else { return } - self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) - } + if emojis.isEmpty { + self.customEmojiPickerInputView.activityIndicatorView.startAnimating() + } else { + self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() + } + }) .store(in: &disposeBag) } @@ -317,6 +363,25 @@ extension ComposeViewController { textEditorView()?.isEditing = true } + private func contentWarningEditorTextView() -> UITextView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .input: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { + continue + } + return cell.statusContentWarningEditorView.textView + default: + continue + } + } + + return nil + } + private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { guard case .pollOption = item else { return nil } guard let diffableDataSource = viewModel.diffableDataSource else { return nil } @@ -398,7 +463,7 @@ extension ComposeViewController { imagePicker.delegate = self return imagePicker } - + } extension ComposeViewController { @@ -587,6 +652,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttributes(attributes, range: match.range) } + if string.count > ComposeViewModel.composeContentLimit { + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.danger.color + let boundStart = string.index(string.startIndex, offsetBy: ComposeViewModel.composeContentLimit) + let boundEnd = string.endIndex + let range = boundStart..() snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 07570d796..036351d87 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-3-11. // +import os.log import UIKit import Combine import CoreData @@ -14,6 +15,8 @@ import MastodonSDK final class ComposeViewModel { + static let composeContentLimit: Int = 500 + var disposeBag = Set() // input @@ -48,11 +51,13 @@ final class ComposeViewModel { let isPublishBarButtonItemEnabled = CurrentValueSubject(false) let isMediaToolbarButtonEnabled = CurrentValueSubject(true) let isPollToolbarButtonEnabled = CurrentValueSubject(true) + let characterCount = CurrentValueSubject(0) // custom emojis var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() + let isLoadingCustomEmoji = CurrentValueSubject(false) // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) @@ -109,10 +114,30 @@ final class ComposeViewModel { } .store(in: &disposeBag) + // bind character count + Publishers.CombineLatest3( + composeStatusAttribute.composeContent.eraseToAnyPublisher(), + composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(), + composeStatusAttribute.contentWarningContent.eraseToAnyPublisher() + ) + .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in + let composeContent = composeContent ?? "" + var count = composeContent.count + if isContentWarningComposing { + count += contentWarningContent.count + } + return count + } + .assign(to: \.value, on: characterCount) + .store(in: &disposeBag) // bind compose bar button item UI state let isComposeContentEmpty = composeStatusAttribute.composeContent .map { ($0 ?? "").isEmpty } - let isComposeContentValid = Just(true).eraseToAnyPublisher() + let isComposeContentValid = composeStatusAttribute.composeContent + .map { composeContent -> Bool in + let composeContent = composeContent ?? "" + return composeContent.count <= ComposeViewModel.composeContentLimit + } let isMediaEmpty = attachmentServices .map { $0.isEmpty } let isMediaUploadAllSuccess = attachmentServices @@ -278,6 +303,10 @@ final class ComposeViewModel { .store(in: &disposeBag) } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ComposeViewModel { @@ -301,6 +330,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update - pollOptionAttributes.value = pollOptionAttributes.value + // pollOptionAttributes.value = pollOptionAttributes.value } } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 8ba879c02..efe408265 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -59,6 +59,14 @@ final class ComposeToolbarView: UIView { return button }() + let characterCountLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.text = "500" + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -102,6 +110,16 @@ extension ComposeToolbarView { ]) } + characterCountLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(characterCountLabel) + NSLayoutConstraint.activate([ + characterCountLabel.topAnchor.constraint(equalTo: topAnchor), + characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8), + characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + mediaButton.menu = createMediaContextMenu() mediaButton.showsMenuAsPrimaryAction = true pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift index 87b1ee481..6bfe31d34 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift @@ -17,6 +17,8 @@ final class CustomEmojiPickerInputView: UIInputView { return collectionView }() + let activityIndicatorView = UIActivityIndicatorView(style: .large) + override init(frame: CGRect, inputViewStyle: UIInputView.Style) { super.init(frame: frame, inputViewStyle: inputViewStyle) _init() @@ -33,6 +35,13 @@ extension CustomEmojiPickerInputView { private func _init() { allowsSelfSizing = true + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + collectionView.translatesAutoresizingMaskIntoConstraints = false addSubview(collectionView) NSLayoutConstraint.activate([ @@ -41,6 +50,9 @@ extension CustomEmojiPickerInputView { collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index dc7b8a47b..b14b42aa8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -15,7 +15,7 @@ final class HomeTimelineNavigationBarView { }() static let offlineView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightDangerRed.color) + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color) let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline) HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) return view diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index d66f9717c..04aea3c19 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -106,7 +106,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let usernameErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -146,7 +146,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let emailErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -177,7 +177,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let passwordErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -201,7 +201,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let reasonErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 7089aef7c..45b4599a9 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -198,10 +198,10 @@ extension MastodonRegisterViewModel { let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.xmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color)) + attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.danger.color)) attributeString.append(NSAttributedString(string: " ")) - let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) + let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.danger.color]) attributeString.append(promptAttributedString) return attributeString From ca25d43f4f2b7ce05d0bc18a49668d4a541584dc Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 29 Mar 2021 13:37:56 +0800 Subject: [PATCH 146/400] feature: Add context menu for avatar select for sign up page --- Localization/app.json | 5 +- Mastodon/Generated/Strings.swift | 4 + .../Resources/en.lproj/Localizable.strings | 1 + ...astodonRegisterViewController+Avatar.swift | 108 +++++++++++++++--- .../MastodonRegisterViewController.swift | 25 +++- 5 files changed, 118 insertions(+), 25 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index c0a305d96..43cc8f6db 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -103,6 +103,9 @@ "register": { "title": "Tell us about you.", "input": { + "avatar": { + "delete": "delete" + }, "username": { "placeholder": "username", "duplicate_prompt": "This username is taken." @@ -228,4 +231,4 @@ } } } -} \ No newline at end of file +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a875994ea..fe8e6294e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -331,6 +331,10 @@ internal enum L10n { } } internal enum Input { + internal enum Avatar { + /// delete + internal static let delete = L10n.tr("Localizable", "Scene.Register.Input.Avatar.Delete") + } internal enum DisplayName { /// display name internal static let placeholder = L10n.tr("Localizable", "Scene.Register.Input.DisplayName.Placeholder") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d2ebb4071..8c93414aa 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -102,6 +102,7 @@ tap the link to confirm your account."; "Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; "Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; "Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)"; +"Scene.Register.Input.Avatar.Delete" = "delete"; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index a23585271..3a25fad74 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -7,10 +7,58 @@ import CropViewController import Foundation +import OSLog import PhotosUI import UIKit +extension MastodonRegisterViewController { + func createMediaContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.present(self.imagePicker, animated: true, completion: nil) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.present(self.imagePickerController, animated: true, completion: nil) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.present(self.documentPickerController, animated: true, completion: nil) + } + children.append(browseAction) + if self.viewModel.avatarImage.value != nil { + let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + self.viewModel.avatarImage.value = nil + self.avatarButton.setImage(nil, for: .normal) + } + children.append(deleteAction) + } + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func cropImage(image:UIImage,pickerViewController:UIViewController) { + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true + cropController.aspectRatioLockEnabled = true + pickerViewController.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } +} + // MARK: - PHPickerViewControllerDelegate + extension MastodonRegisterViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { @@ -20,11 +68,11 @@ extension MastodonRegisterViewController: PHPickerViewControllerDelegate { itemProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in guard let self = self else { return } guard let image = image as? UIImage else { - guard let error = error else { return } - let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) - alertController.addAction(okAction) DispatchQueue.main.async { + guard let error = error else { return } + let alertController = UIAlertController(for: error, title: "", preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) self.coordinator.present( scene: .alertController(alertController: alertController), from: nil, @@ -33,21 +81,48 @@ extension MastodonRegisterViewController: PHPickerViewControllerDelegate { } return } - DispatchQueue.main.async { - let cropController = CropViewController(croppingStyle: .default, image: image) - cropController.delegate = self - cropController.setAspectRatioPreset(.presetSquare, animated: true) - cropController.aspectRatioPickerButtonHidden = true - cropController.aspectRatioLockEnabled = true - picker.dismiss(animated: true, completion: { - self.present(cropController, animated: true, completion: nil) - }) - } + self.cropImage(image: image, pickerViewController: picker) + } + } +} + +// MARK: - UIImagePickerControllerDelegate + +extension MastodonRegisterViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + + cropImage(image: image, pickerViewController: picker) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate + +extension MastodonRegisterViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + guard let image = UIImage(data: imageData) else { return } + cropImage(image: image, pickerViewController: controller) + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) } } } // MARK: - CropViewControllerDelegate + extension MastodonRegisterViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { self.viewModel.avatarImage.value = image @@ -56,8 +131,3 @@ extension MastodonRegisterViewController: CropViewControllerDelegate { } } -extension MastodonRegisterViewController { - @objc func avatarButtonPressed(_ sender: UIButton) { - self.present(imagePicker, animated: true, completion: nil) - } -} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 04aea3c19..8f0162cd3 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -20,14 +20,28 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O var viewModel: MastodonRegisterViewModel! - lazy var imagePicker: PHPickerViewController = { + // picker + private(set) lazy var imagePicker: PHPickerViewController = { var configuration = PHPickerConfiguration() configuration.filter = .images + configuration.selectionLimit = 1 let imagePicker = PHPickerViewController(configuration: configuration) imagePicker.delegate = self return imagePicker }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open) + documentPickerController.delegate = self + return documentPickerController + }() let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer @@ -56,7 +70,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O }() let avatarButton: UIButton = { - let button = UIButton(type: .custom) + let button = HighlightDimmableButton() let boldFont = UIFont.systemFont(ofSize: 42) let configuration = UIImage.SymbolConfiguration(font: boldFont) let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) @@ -227,6 +241,9 @@ extension MastodonRegisterViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + avatarButton.menu = createMediaContextMenu() + avatarButton.showsMenuAsPrimaryAction = true + domainLabel.text = "@" + viewModel.domain + " " domainLabel.sizeToFit() passwordCheckLabel.attributedText = MastodonRegisterViewModel.attributeStringForPassword(validateState: .empty) @@ -388,9 +405,8 @@ extension MastodonRegisterViewController { .receive(on: DispatchQueue.main) .sink { [weak self] isHighlighted in guard let self = self else { return } - let alpha: CGFloat = isHighlighted ? 0.8 : 1 + let alpha: CGFloat = isHighlighted ? 0.6 : 1 self.plusIconImageView.alpha = alpha - self.avatarButton.alpha = alpha } .store(in: &disposeBag) @@ -550,7 +566,6 @@ extension MastodonRegisterViewController { .store(in: &disposeBag) } - avatarButton.addTarget(self, action: #selector(MastodonRegisterViewController.avatarButtonPressed(_:)), for: .touchUpInside) signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) } From e3fa472f3ff7a9ffa126c0812b2aa0cd4417450c Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 29 Mar 2021 17:44:52 +0800 Subject: [PATCH 147/400] feat: implement post publishing progress bar UI and publish failure retry logic --- Localization/app.json | 5 + Mastodon.xcodeproj/project.pbxproj | 28 ++- Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 8 + .../Background/success.colorset/Contents.json | 20 ++ .../Resources/en.lproj/Localizable.strings | 4 + .../Scene/Compose/ComposeViewController.swift | 2 +- .../ComposeViewModel+PublishState.swift | 11 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 9 +- .../HomeTimelineNavigationBarState.swift | 156 ------------- .../HomeTimelineNavigationBarView.swift | 80 ------- .../HomeTimelineViewController.swift | 85 ++++++- ...omeTimelineViewModel+LoadLatestState.swift | 9 +- ...omeTimelineViewModel+LoadMiddleState.swift | 2 +- ...omeTimelineViewModel+LoadOldestState.swift | 2 +- .../HomeTimeline/HomeTimelineViewModel.swift | 9 +- .../HomeTimelineNavigationBarTitleView.swift | 210 ++++++++++++++++++ ...eTimelineNavigationBarTitleViewModel.swift | 174 +++++++++++++++ .../Scene/MainTab/MainTabBarController.swift | 26 +++ .../Service/StatusPrefetchingService.swift | 4 +- Mastodon/Service/StatusPublishService.swift | 78 +++++++ Mastodon/Service/ViedeoPlaybackService.swift | 2 +- Mastodon/State/AppContext.swift | 1 + 23 files changed, 657 insertions(+), 269 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json delete mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift delete mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift create mode 100644 Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift create mode 100644 Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift create mode 100644 Mastodon/Service/StatusPublishService.swift diff --git a/Localization/app.json b/Localization/app.json index c0a305d96..99868a8fb 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -18,6 +18,10 @@ "discard_post_content": { "title": "Discard Publish", "message": "Confirm discard composed post content." + }, + "publish_post_failure": { + "title": "Publish Failure", + "message": "Failed to publish the post.\nPlease check your internet connection." } }, "controls": { @@ -32,6 +36,7 @@ "continue": "Continue", "cancel": "Cancel", "discard": "Discard", + "try_again": "Try Again", "take_photo": "Take photo", "save_photo": "Save photo", "sign_in": "Sign In", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index aff684185..f0d5fa084 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -73,8 +73,8 @@ 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; - 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */; }; - 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */; }; + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; @@ -246,6 +246,7 @@ DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; + DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -370,8 +371,8 @@ 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; - 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarState.swift; sourceTree = ""; }; - 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarView.swift; sourceTree = ""; }; + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = ""; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; @@ -555,6 +556,7 @@ DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; + DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -707,6 +709,7 @@ 2D38F1D325CD463600561493 /* HomeTimeline */ = { isa = PBXGroup; children = ( + DB1F239626117C360057430E /* View */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, @@ -715,8 +718,6 @@ 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */, 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */, 2D38F1F625CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift */, - 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarState.swift */, - 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift */, ); path = HomeTimeline; sourceTree = ""; @@ -785,6 +786,7 @@ 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, + DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, ); path = Service; sourceTree = ""; @@ -970,6 +972,15 @@ path = TableView; sourceTree = ""; }; + DB1F239626117C360057430E /* View */ = { + isa = PBXGroup; + children = ( + 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */, + 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; DB3D0FF725BAA68500EAA174 /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -1832,12 +1843,12 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, - 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarView.swift in Sources */, + 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, - 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarState.swift in Sources */, + 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -1925,6 +1936,7 @@ 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8bf3b168b..82e7f8b1f 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,6 +44,7 @@ internal enum Asset { internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") + internal static let success = ColorAsset(name: "Colors/Background/success") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a875994ea..82fa696d8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -25,6 +25,12 @@ internal enum L10n { /// Discard Publish internal static let title = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Title") } + internal enum PublishPostFailure { + /// Failed to publish the post.\nPlease check your internet connection. + internal static let message = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Message") + /// Publish Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -76,6 +82,8 @@ internal enum L10n { internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") /// Take photo internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") + /// Try Again + internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") } internal enum Status { /// Tap to reveal that may be sensitive diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json new file mode 100644 index 000000000..8716dcb74 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.604", + "green" : "0.741", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d2ebb4071..c2bc09c68 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -2,6 +2,9 @@ "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; +"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. +Please check your internet connection."; +"Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; @@ -23,6 +26,7 @@ "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; +"Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 5e3a6a8a9..53f71fc31 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -483,7 +483,7 @@ extension ComposeViewController { // TODO: handle error return } - + context.statusPublishService.publish(composeViewModel: viewModel) dismiss(animated: true, completion: nil) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index d5047cc9f..c3e903812 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -21,6 +21,7 @@ extension ComposeViewModel { override func didEnter(from previousState: GKState?) { os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.publishStateMachinePublisher.value = self } } } @@ -48,6 +49,8 @@ extension ComposeViewModel.PublishState { return } + viewModel.updatePublishDate() + let domain = mastodonAuthenticationBox.domain let attachmentServices = viewModel.attachmentServices.value let mediaIDs = attachmentServices.compactMap { attachmentService in @@ -131,7 +134,13 @@ extension ComposeViewModel.PublishState { class Fail: ComposeViewModel.PublishState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { // allow discard publishing - return stateClass == Publishing.self || stateClass == Finish.self + return stateClass == Publishing.self || stateClass == Discard.self + } + } + + class Discard: ComposeViewModel.PublishState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 036351d87..52ca4cc88 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -39,12 +39,15 @@ final class ComposeViewModel { PublishState.Initial(viewModel: self), PublishState.Publishing(viewModel: self), PublishState.Fail(viewModel: self), + PublishState.Discard(viewModel: self), PublishState.Finish(viewModel: self), ]) stateMachine.enter(PublishState.Initial.self) return stateMachine }() - + private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) + private(set) var publishDate = Date() // update it when enter Publishing state + // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) @@ -316,6 +319,10 @@ extension ComposeViewModel { let attribute = ComposeStatusItem.ComposePollOptionAttribute() pollOptionAttributes.value = pollOptionAttributes.value + [attribute] } + + func updatePublishDate() { + publishDate = Date() + } } // MARK: - MastodonAttachmentServiceDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift deleted file mode 100644 index 2d1da2165..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarState.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// HomeTimelineNavigationBarState.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import Combine -import Foundation -import UIKit - -final class HomeTimelineNavigationBarState { - static let errorCountMax: Int = 3 - var disposeBag = Set() - var errorCountDownDispose: AnyCancellable? - var timerDispose: AnyCancellable? - var networkErrorCountSubject = PassthroughSubject() - - var newTopContent = CurrentValueSubject(false) - var hasContentBeforeFetching: Bool = true - - weak var viewController: HomeTimelineViewController? - - let timestampUpdatePublisher = Timer.publish(every: NavigationBarProgressView.progressAnimationDuration, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - - init() { - reCountdown() - subscribeNewContent() - addGesture() - } -} - -extension HomeTimelineNavigationBarState { - func showOfflineInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.offlineView - } - - func showNewPostsInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.newPostsView - } - - func showPublishingNewPostInNavigationBar() { - let progressView = HomeTimelineNavigationBarView.progressView - if let navigationBar = viewController?.navigationBar(), progressView.superview == nil { - navigationBar.addSubview(progressView) - NSLayoutConstraint.activate([ - progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), - progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), - progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), - progressView.heightAnchor.constraint(equalToConstant: 3) - ]) - } - progressView.layoutIfNeeded() - progressView.progress = 0 - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishingLabel - - var times: Int = 0 - timerDispose = timestampUpdatePublisher - .map { _ in - times += 1 - return Double(times) - } - .scan(0) { value, count in - value + 1 / pow(Double(2), count) - } - .receive(on: DispatchQueue.main) - .sink { value in - print(value) - progressView.progress = CGFloat(value) - } - } - - func showPublishedInNavigationBar() { - timerDispose = nil - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.publishedView - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) { - self.showMastodonLogoInNavigationBar() - } - } - - func showMastodonLogoInNavigationBar() { - HomeTimelineNavigationBarView.progressView.removeFromSuperview() - viewController?.navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView - } -} - -extension HomeTimelineNavigationBarState { - func handleScrollViewDidScroll(_ scrollView: UIScrollView) { - let contentOffsetY = scrollView.contentOffset.y - let isShowingNewPostsNew = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.newPostsView - if !isShowingNewPostsNew { - return - } - let isTop = contentOffsetY < -scrollView.contentInset.top - if isTop { - newTopContent.value = false - showMastodonLogoInNavigationBar() - } - } - - func addGesture() { - let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer - tapGesture.addTarget(self, action: #selector(HomeTimelineNavigationBarState.newPostsNewDidPressed(_:))) - HomeTimelineNavigationBarView.newPostsView.addGestureRecognizer(tapGesture) - } - - @objc func newPostsNewDidPressed(_ sender: UITapGestureRecognizer) { - if newTopContent.value == true { - viewController?.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: true) - } - } -} - -extension HomeTimelineNavigationBarState { - func subscribeNewContent() { - newTopContent - .receive(on: DispatchQueue.main) - .sink { [weak self] newContent in - guard let self = self else { return } - if self.hasContentBeforeFetching, newContent { - self.showNewPostsInNavigationBar() - } - } - .store(in: &disposeBag) - } - - func reCountdown() { - errorCountDownDispose = networkErrorCountSubject - .scan(0) { value, _ in value + 1 } - .sink(receiveValue: { [weak self] errorCount in - guard let self = self else { return } - if errorCount >= HomeTimelineNavigationBarState.errorCountMax { - self.showOfflineInNavigationBar() - } - }) - } - - func receiveCompletion(completion: Subscribers.Completion) { - switch completion { - case .failure: - networkErrorCountSubject.send(false) - case .finished: - reCountdown() - let isShowingOfflineView = viewController?.navigationItem.titleView === HomeTimelineNavigationBarView.offlineView - if isShowingOfflineView { - showMastodonLogoInNavigationBar() - } - } - } -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift deleted file mode 100644 index b14b42aa8..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// HomeTimelineNavigationBarView.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/15. -// - -import UIKit - -final class HomeTimelineNavigationBarView { - static let mastodonLogoTitleView: UIImageView = { - let imageView = UIImageView(image: Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate)) - imageView.tintColor = Asset.Colors.Label.primary.color - return imageView - }() - - static let offlineView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static let newPostsView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.Button.normal.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.newPosts) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static var publishedView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightSuccessGreen.color) - let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.published) - HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) - return view - }() - - static var progressView: NavigationBarProgressView = { - let view = NavigationBarProgressView() - return view - }() - - static var publishingLabel: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .black - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) - label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing - return label - }() - - static func addLabelToView(label: UILabel, view: UIView) { - view.addSubview(label) - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - view.trailingAnchor.constraint(equalTo: label.trailingAnchor, constant: 16), - label.topAnchor.constraint(equalTo: view.topAnchor, constant: 1), - view.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 1), - view.heightAnchor.constraint(equalToConstant: 24), - ]) - } - - static func backgroundViewWithColor(color: UIColor) -> UIView { - let view = UIView() - view.backgroundColor = color - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 12 - view.clipsToBounds = true - return view - } - - static func contentLabel(text: String) -> UILabel { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .white - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .bold)) - label.text = text - return label - } -} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 6efdb76a3..078b7b445 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + let titleView = HomeTimelineNavigationBarTitleView() + let settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = Asset.Colors.Label.highlight.color @@ -49,6 +51,12 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { return tableView }() + let publishProgressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .bar) + progressView.alpha = 0 + return progressView + }() + let refreshControl = UIRefreshControl() deinit { @@ -64,8 +72,19 @@ extension HomeTimelineViewController { title = L10n.Scene.HomeTimeline.title view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - navigationItem.titleView = HomeTimelineNavigationBarView.mastodonLogoTitleView navigationItem.leftBarButtonItem = settingBarButtonItem + navigationItem.titleView = titleView + titleView.delegate = self + + viewModel.homeTimelineNavigationBarTitleViewModel.state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + self.titleView.configure(state: state) + } + .store(in: &disposeBag) + + #if DEBUG // long press to trigger debug menu settingBarButtonItem.menu = debugMenu @@ -95,9 +114,16 @@ extension HomeTimelineViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + + publishProgressView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(publishProgressView) + NSLayoutConstraint.activate([ + publishProgressView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + publishProgressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + publishProgressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) viewModel.tableView = tableView - viewModel.viewController = self viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self tableView.delegate = self tableView.prefetchDataSource = self @@ -121,9 +147,35 @@ extension HomeTimelineViewController { } } .store(in: &disposeBag) + + viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress + .receive(on: DispatchQueue.main) + .sink { [weak self] progress in + guard let self = self else { return } + guard progress > 0 else { + let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) + dismissAnimator.addAnimations { + self.publishProgressView.alpha = 0 + } + dismissAnimator.addCompletion { _ in + self.publishProgressView.setProgress(0, animated: false) + } + dismissAnimator.startAnimation() + return + } + if self.publishProgressView.alpha == 0 { + let progressAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeOut) + progressAnimator.addAnimations { + self.publishProgressView.alpha = 1 + } + progressAnimator.startAnimation() + } + + self.publishProgressView.setProgress(progress, animated: true) + } + .store(in: &disposeBag) } - override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -207,7 +259,7 @@ extension HomeTimelineViewController { extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) - self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } @@ -221,8 +273,9 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - UITableViewDelegate extension HomeTimelineViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 200 // TODO: - // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } // @@ -232,7 +285,7 @@ extension HomeTimelineViewController: UITableViewDelegate { // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) // // return ceil(frame.height) - // } + } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) @@ -364,3 +417,23 @@ extension HomeTimelineViewController: StatusTableViewCellDelegate { weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } func parent() -> UIViewController { return self } } + +// MARK: - HomeTimelineNavigationBarTitleViewDelegate +extension HomeTimelineViewController: HomeTimelineNavigationBarTitleViewDelegate { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) { + switch titleView.state { + case .newPostButton: + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let indexPath = IndexPath(row: 0, section: 0) + guard diffableDataSource.itemIdentifier(for: indexPath) != nil else { return } + tableView.scrollToRow(at: indexPath, at: .top, animated: true) + case .offlineButton: + // TODO: retry + break + case .publishedButton: + break + default: + break + } + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 0df4334a0..80c86a006 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -73,7 +73,7 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Fail.self) return } - viewModel.homeTimelineNavigationBarState.hasContentBeforeFetching = !latestTootIDs.isEmpty + let end = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) @@ -81,7 +81,7 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): // TODO: handle error @@ -102,9 +102,10 @@ extension HomeTimelineViewModel.LoadLatestState { if newToots.isEmpty { viewModel.isFetchingLatestTimeline.value = false - viewModel.homeTimelineNavigationBarState.newTopContent.value = false } else { - viewModel.homeTimelineNavigationBarState.newTopContent.value = true + if !latestTootIDs.isEmpty { + viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() + } } } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index bb1211d2f..07b6abf17 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -68,7 +68,7 @@ extension HomeTimelineViewModel.LoadMiddleState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): // TODO: handle error diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index b18a66c01..341183dcf 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -58,7 +58,7 @@ extension HomeTimelineViewModel.LoadOldestState { .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) .sink { completion in - viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 263e76a9d..7b9c35308 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -28,17 +28,11 @@ final class HomeTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let viewDidAppear = PassthroughSubject() - - let homeTimelineNavigationBarState = HomeTimelineNavigationBarState() + let homeTimelineNavigationBarTitleViewModel: HomeTimelineNavigationBarTitleViewModel weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? - weak var viewController: HomeTimelineViewController? { - willSet(value) { - self.homeTimelineNavigationBarState.viewController = value - } - } // output // top loader @@ -90,6 +84,7 @@ final class HomeTimelineViewModel: NSObject { return controller }() + self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() fetchedResultsController.delegate = self diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift new file mode 100644 index 000000000..604c0915d --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -0,0 +1,210 @@ +// +// HomeTimelineNavigationBarTitleView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import os.log +import UIKit + +protocol HomeTimelineNavigationBarTitleViewDelegate: class { + func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) +} + +final class HomeTimelineNavigationBarTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} + +extension HomeTimelineNavigationBarTitleView { + @objc private func buttonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.homeTimelineNavigationBarTitleView(self, buttonDidPressed: sender) + } +} + +extension HomeTimelineNavigationBarTitleView { + + func resetContainer() { + imageView.isHidden = true + button.isHidden = true + label.isHidden = true + } + + func configure(state: HomeTimelineNavigationBarTitleViewModel.State) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: configure title view: %s", ((#file as NSString).lastPathComponent), #line, #function, state.rawValue) + self.state = state + + // check state block or not + guard blockingState == nil else { + return + } + + resetContainer() + + switch state { + case .logoImage: + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.image = Asset.Asset.mastodonTextLogo.image.withRenderingMode(.alwaysTemplate) + imageView.contentMode = .center + imageView.isHidden = false + case .newPostButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.newPosts, + textColor: .white, + backgroundColor: Asset.Colors.Button.normal.color + ) + button.isHidden = false + case .offlineButton: + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.offline, + textColor: .white, + backgroundColor: Asset.Colors.Background.danger.color + ) + button.isHidden = false + case .publishingPostLabel: + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.HomeTimeline.NavigationBarState.publishing + label.textAlignment = .center + label.isHidden = false + case .publishedButton: + blockingState = state + configureButton( + title: L10n.Scene.HomeTimeline.NavigationBarState.published, + textColor: .white, + backgroundColor: Asset.Colors.Background.success.color + ) + button.isHidden = false + + let presentDuration: TimeInterval = 0.33 + let scaleAnimator = UIViewPropertyAnimator(duration: presentDuration, timingParameters: UISpringTimingParameters()) + button.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + scaleAnimator.addAnimations { + self.button.transform = .identity + } + let alphaAnimator = UIViewPropertyAnimator(duration: presentDuration, curve: .easeInOut) + button.alpha = 0.3 + alphaAnimator.addAnimations { + self.button.alpha = 1 + } + scaleAnimator.startAnimation() + alphaAnimator.startAnimation() + + let dismissDuration: TimeInterval = 3 + let dissolveAnimator = UIViewPropertyAnimator(duration: dismissDuration, curve: .easeInOut) + dissolveAnimator.addAnimations({ + self.button.alpha = 0 + }, delayFactor: 0.9) // at 2.7s + dissolveAnimator.addCompletion { _ in + self.blockingState = nil + self.configure(state: self.state) + self.button.alpha = 1 + } + dissolveAnimator.startAnimation() + } + } + + private func configureButton(title: String, textColor: UIColor, backgroundColor: UIColor) { + button.setBackgroundImage(.placeholder(color: backgroundColor), for: .normal) + button.setBackgroundImage(.placeholder(color: backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + button.setTitleColor(textColor, for: .normal) + button.setTitleColor(textColor.withAlphaComponent(0.5), for: .highlighted) + button.setTitle(title, for: .normal) + button.contentEdgeInsets = UIEdgeInsets(top: 1, left: 16, bottom: 1, right: 16) + button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold) + } + +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct HomeTimelineNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .logoImage) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 150) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .newPostButton) + return titleView + } + .previewLayout(.fixed(width: 150, height: 24)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .offlineButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + UIViewPreview(width: 375) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishingPostLabel) + return titleView + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 120) { + let titleView = HomeTimelineNavigationBarTitleView() + titleView.configure(state: .publishedButton) + return titleView + } + .previewLayout(.fixed(width: 120, height: 24)) + } + } + +} + +#endif + diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift new file mode 100644 index 000000000..e1fc3174e --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift @@ -0,0 +1,174 @@ +// +// HomeTimelineNavigationBarTitleViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/15. +// + +import Combine +import Foundation +import UIKit + +final class HomeTimelineNavigationBarTitleViewModel { + + static let offlineCounterLimit = 3 + + var disposeBag = Set() + private(set) var publishingProgressSubscription: AnyCancellable? + + // input + let context: AppContext + var networkErrorCount = CurrentValueSubject(0) + var networkErrorPublisher = PassthroughSubject() + + // output + let state = CurrentValueSubject(.logoImage) + let hasNewPosts = CurrentValueSubject(false) + let isOffline = CurrentValueSubject(false) + let isPublishingPost = CurrentValueSubject(false) + let isPublished = CurrentValueSubject(false) + let publishingProgress = PassthroughSubject() + + init(context: AppContext) { + self.context = context + + networkErrorPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.networkErrorCount.value += self.networkErrorCount.value + 1 + } + .store(in: &disposeBag) + + networkErrorCount + .receive(on: DispatchQueue.main) + .map { count in + return count >= HomeTimelineNavigationBarTitleViewModel.offlineCounterLimit + } + .assign(to: \.value, on: isOffline) + .store(in: &disposeBag) + + context.statusPublishService.latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel, + let state = composeViewModel.publishStateMachine.currentState else { + self.isPublishingPost.value = false + self.isPublished.value = false + return + } + + self.isPublishingPost.value = state is ComposeViewModel.PublishState.Publishing || state is ComposeViewModel.PublishState.Fail + self.isPublished.value = state is ComposeViewModel.PublishState.Finish + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + hasNewPosts.eraseToAnyPublisher(), + isOffline.eraseToAnyPublisher(), + isPublishingPost.eraseToAnyPublisher(), + isPublished.eraseToAnyPublisher() + ) + .map { hasNewPosts, isOffline, isPublishingPost, isPublished -> State in + guard !isPublished else { return .publishedButton } + guard !isPublishingPost else { return .publishingPostLabel } + guard !isOffline else { return .offlineButton } + guard !hasNewPosts else { return .newPostButton } + return .logoImage + } + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: state) + .store(in: &disposeBag) + + state + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .publishingPostLabel: + self.setupPublishingProgress() + default: + self.suspendPublishingProgress() + } + } + .store(in: &disposeBag) + } +} + +extension HomeTimelineNavigationBarTitleViewModel { + // state order by priority from low to high + enum State: String { + case logoImage + case newPostButton + case offlineButton + case publishingPostLabel + case publishedButton + } +} + +// MARK: - New post state +extension HomeTimelineNavigationBarTitleViewModel { + + func newPostsIncoming() { + hasNewPosts.value = true + } + + private func resetNewPostState() { + hasNewPosts.value = false + } + +} + +// MARK: - Offline state +extension HomeTimelineNavigationBarTitleViewModel { + + func resetOfflineCounterListener() { + networkErrorCount.value = 0 + } + + func receiveLoadingStateCompletion(_ completion: Subscribers.Completion) { + switch completion { + case .failure: + networkErrorPublisher.send() + case .finished: + resetOfflineCounterListener() + } + } + + func handleScrollViewDidScroll(_ scrollView: UIScrollView) { + guard hasNewPosts.value else { return } + + let contentOffsetY = scrollView.contentOffset.y + let isScrollToTop = contentOffsetY < -scrollView.contentInset.top + guard isScrollToTop else { return } + resetNewPostState() + } + +} + +// MARK: Publish post state +extension HomeTimelineNavigationBarTitleViewModel { + + func setupPublishingProgress() { + let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS + .autoconnect() + .share() + .eraseToAnyPublisher() + + publishingProgressSubscription = progressUpdatePublisher + .map { _ in Float(0) } + .scan(0.0) { progress, _ -> Float in + return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS) + } + .subscribe(publishingProgress) + } + + func suspendPublishingProgress() { + publishingProgressSubscription = nil + publishingProgress.send(0) + } + +} + diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index a556854e5..72f62528a 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -123,6 +123,32 @@ extension MainTabBarController { } } .store(in: &disposeBag) + + // handle post failure + context.statusPublishService + .latestPublishingComposeViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] composeViewModel in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + guard let currentState = composeViewModel.publishStateMachine.currentState else { return } + guard currentState is ComposeViewModel.PublishState.Fail else { return } + + let alertController = UIAlertController(title: L10n.Common.Alerts.PublishPostFailure.title, message: L10n.Common.Alerts.PublishPostFailure.message, preferredStyle: .alert) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self, weak composeViewModel] _ in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + self.context.statusPublishService.remove(composeViewModel: composeViewModel) + } + alertController.addAction(discardAction) + let retryAction = UIAlertAction(title: L10n.Common.Controls.Actions.tryAgain, style: .default) { [weak composeViewModel] _ in + guard let composeViewModel = composeViewModel else { return } + composeViewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) + } + alertController.addAction(retryAction) + self.present(alertController, animated: true, completion: nil) + } + .store(in: &disposeBag) #if DEBUG // selectedIndex = 1 diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift index d4332fe16..5d0191ff3 100644 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -16,8 +16,8 @@ final class StatusPrefetchingService { typealias TaskID = String - let workingQueue = DispatchQueue(label: "status-prefetching-service-working-queue") - + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPrefetchingService.working-queue") + var disposeBag = Set() private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] diff --git a/Mastodon/Service/StatusPublishService.swift b/Mastodon/Service/StatusPublishService.swift new file mode 100644 index 000000000..4728af8c1 --- /dev/null +++ b/Mastodon/Service/StatusPublishService.swift @@ -0,0 +1,78 @@ +// +// StatusPublishService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-26. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusPublishService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.StatusPublishService.working-queue") + + // input + var viewModels = CurrentValueSubject<[ComposeViewModel], Never>([]) // use strong reference to retain the view models + + // output + let composeViewModelDidUpdatePublisher = PassthroughSubject() + let latestPublishingComposeViewModel = CurrentValueSubject(nil) + + init() { + Publishers.CombineLatest( + viewModels.eraseToAnyPublisher(), + composeViewModelDidUpdatePublisher.eraseToAnyPublisher() + ) + .map { viewModels, _ in viewModels.last } + .assign(to: \.value, on: latestPublishingComposeViewModel) + .store(in: &disposeBag) + } + +} + +extension StatusPublishService { + + func publish(composeViewModel: ComposeViewModel) { + workingQueue.sync { + guard !self.viewModels.value.contains(where: { $0 === composeViewModel }) else { return } + self.viewModels.value = self.viewModels.value + [composeViewModel] + + composeViewModel.publishStateMachinePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak composeViewModel] state in + guard let self = self else { return } + guard let composeViewModel = composeViewModel else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModelDidUpdate", ((#file as NSString).lastPathComponent), #line, #function) + self.composeViewModelDidUpdatePublisher.send() + + switch state { + case is ComposeViewModel.PublishState.Finish: + self.remove(composeViewModel: composeViewModel) + default: + break + } + } + .store(in: &composeViewModel.disposeBag) // cancel subscription when viewModel dealloc + } + } + + func remove(composeViewModel: ComposeViewModel) { + workingQueue.async { + var viewModels = self.viewModels.value + viewModels.removeAll(where: { $0 === composeViewModel }) + self.viewModels.value = viewModels + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: composeViewModel removed", ((#file as NSString).lastPathComponent), #line, #function) + + } + } + +} diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 24ea6e6ce..15348c6e9 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -14,7 +14,7 @@ import os.log final class VideoPlaybackService { var disposeBag = Set() - let workingQueue = DispatchQueue(label: "com.twidere.twiderex.video-playback-service.working-queue") + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.VideoPlaybackService.working-queue") private(set) var viewPlayerViewModelDict: [URL: VideoPlayerViewModel] = [:] // only for video kind diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 28325f94e..903cb7693 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -27,6 +27,7 @@ class AppContext: ObservableObject { let audioPlaybackService = AudioPlaybackService() let videoPlaybackService = VideoPlaybackService() let statusPrefetchingService: StatusPrefetchingService + let statusPublishService = StatusPublishService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! From d9533deccfb4cb4a9e6c1d96e1bb85afa864979a Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 29 Mar 2021 17:45:19 +0800 Subject: [PATCH 148/400] chore: update version to 0.3.0 (3) --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f0d5fa084..22d59f747 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -2258,7 +2258,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -2266,7 +2266,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2285,7 +2285,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -2293,7 +2293,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1.0; + MARKETING_VERSION = 0.3.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From ed88923901dbb90e0412dd10d9f020c3ddd71d48 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Mon, 29 Mar 2021 22:02:27 +0800 Subject: [PATCH 149/400] fix: adjust empty state view horizontal padding --- Mastodon.xcodeproj/project.pbxproj | 140 +++++++++--------- .../MastodonPickServerViewController.swift | 7 +- 2 files changed, 75 insertions(+), 72 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index aff684185..3887c1a17 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -42,7 +42,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1FD25CD481700561493 /* StatusProvider.swift */; }; 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; - 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; + 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */; }; 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; @@ -52,14 +52,14 @@ 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; - 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; }; + 2D5981BA25E4D7F8000FB903 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */; }; 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */; }; 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; - 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; + 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; @@ -81,7 +81,7 @@ 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; - 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; + 2D939AC825EE14620076FA61 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; @@ -95,7 +95,7 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; - 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; @@ -104,10 +104,11 @@ 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; - DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; + DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; @@ -125,7 +126,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; + DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; @@ -162,7 +163,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; - DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; + DB5086B825CC0D6400C2C187 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; @@ -228,7 +229,7 @@ DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; - DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; + DB9A487E2603456B008B817C /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* SwiftPackageProductDependency */; }; DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; @@ -250,8 +251,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; - DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; - DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DBE64A8B260C49D200E6359A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */; }; + DBE64A8C260C49D200E6359A /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -299,7 +300,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, + DBE64A8C260C49D200E6359A /* BuildFile in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -569,17 +570,18 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, + DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, - 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, - DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, - 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, - 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, - DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, - 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, - DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, - DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, - 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, + 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */, + DB9A487E2603456B008B817C /* BuildFile in Frameworks */, + 2D939AC825EE14620076FA61 /* BuildFile in Frameworks */, + 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */, + DB5086B825CC0D6400C2C187 /* BuildFile in Frameworks */, + 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */, + DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */, + DBE64A8B260C49D200E6359A /* BuildFile in Frameworks */, + 2D5981BA25E4D7F8000FB903 /* BuildFile in Frameworks */, + 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1479,16 +1481,16 @@ ); name = Mastodon; packageProductDependencies = ( - DB3D0FF225BAA61700EAA174 /* AlamofireImage */, - 5D526FE125BE9AC400460CB9 /* MastodonSDK */, - 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */, - 2D42FF6025C8177C004A627A /* ActiveLabel */, - DB0140BC25C40D7500F9F3CF /* CommonOSLog */, - DB5086B725CC0D6400C2C187 /* Kingfisher */, - 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, - 2D939AC725EE14620076FA61 /* CropViewController */, - DB9A487D2603456B008B817C /* UITextView+Placeholder */, - DBE64A8A260C49D200E6359A /* TwitterTextEditor */, + DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */, + 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */, + 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */, + 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */, + DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */, + DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */, + 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */, + 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */, + DB9A487D2603456B008B817C /* SwiftPackageProductDependency */, + DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1611,15 +1613,15 @@ ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( - DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */, - 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */, - 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */, - DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, - DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, - 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, - 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, - DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, - DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */, + 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */, + 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */, + DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */, + DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */, + 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */, + 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */, + DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */, + DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2529,7 +2531,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */ = { + 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { @@ -2537,7 +2539,7 @@ minimumVersion = 4.0.0; }; }; - 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = { + 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/vtourraine/ThirdPartyMailer.git"; requirement = { @@ -2545,7 +2547,7 @@ minimumVersion = 1.7.1; }; }; - 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */ = { + 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireNetworkActivityIndicator"; requirement = { @@ -2553,7 +2555,7 @@ minimumVersion = 3.1.0; }; }; - 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = { + 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TimOliver/TOCropViewController.git"; requirement = { @@ -2561,7 +2563,7 @@ minimumVersion = 2.6.0; }; }; - DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { + DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/CommonOSLog"; requirement = { @@ -2569,7 +2571,7 @@ minimumVersion = 0.1.1; }; }; - DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { + DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; requirement = { @@ -2577,7 +2579,7 @@ minimumVersion = 4.1.0; }; }; - DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { @@ -2585,7 +2587,7 @@ minimumVersion = 6.1.0; }; }; - DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { + DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; requirement = { @@ -2593,7 +2595,7 @@ minimumVersion = 1.4.1; }; }; - DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; requirement = { @@ -2604,53 +2606,53 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2D42FF6025C8177C004A627A /* ActiveLabel */ = { + 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */; + package = 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */; productName = ActiveLabel; }; - 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */ = { + 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */; + package = 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */; productName = ThirdPartyMailer; }; - 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */ = { + 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; + package = 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */; productName = AlamofireNetworkActivityIndicator; }; - 2D939AC725EE14620076FA61 /* CropViewController */ = { + 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */; + package = 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */; productName = CropViewController; }; - 5D526FE125BE9AC400460CB9 /* MastodonSDK */ = { + 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; - DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { + DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + package = DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */; productName = CommonOSLog; }; - DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { + DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + package = DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */; productName = AlamofireImage; }; - DB5086B725CC0D6400C2C187 /* Kingfisher */ = { + DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; + package = DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */; productName = Kingfisher; }; - DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { + DB9A487D2603456B008B817C /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; + package = DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */; productName = "UITextView+Placeholder"; }; - DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { + DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */ = { isa = XCSwiftPackageProductDependency; - package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + package = DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */; productName = TwitterTextEditor; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 37e8b0dab..53f9a915a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -21,7 +21,8 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency private var expandServerDomainSet = Set() - let emptyStateView = PickServerEmptyStateView() + private let emptyStateView = PickServerEmptyStateView() + private let emptyStateViewHPadding: CGFloat = 4 // UIView's readableContentGuide is 4pt smaller then UITableViewCell's let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! @@ -74,8 +75,8 @@ extension MastodonPickServerViewController { view.addSubview(emptyStateView) NSLayoutConstraint.activate([ emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: emptyStateViewHPadding), + emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor, constant: -emptyStateViewHPadding), nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) From 85e77150b275dc67c36487a221b341148d055125 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 10:37:38 +0800 Subject: [PATCH 150/400] fix: avatar image delete and restore --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../MastodonRegisterViewController+Avatar.swift | 2 -- .../MastodonRegisterViewController.swift | 17 ++++++++++++++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4f2051528..89055cd4a 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -105,7 +105,7 @@ "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", "state": { "branch": "feature/input-view", - "revision": "03e7b7497d424d96268f5bcca1f8e9955bb80fea", + "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5", "version": null } }, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 5e3a6a8a9..9df1225ed 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -98,7 +98,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { }() private(set) lazy var documentPickerController: UIDocumentPickerViewController = { - let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open) + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) documentPickerController.delegate = self return documentPickerController }() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 3a25fad74..5c5f77600 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -35,7 +35,6 @@ extension MastodonRegisterViewController { let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in guard let self = self else { return } self.viewModel.avatarImage.value = nil - self.avatarButton.setImage(nil, for: .normal) } children.append(deleteAction) } @@ -126,7 +125,6 @@ extension MastodonRegisterViewController: UIDocumentPickerDelegate { extension MastodonRegisterViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { self.viewModel.avatarImage.value = image - self.avatarButton.setImage(image, for: .normal) cropViewController.dismiss(animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 8f0162cd3..2bfa138b4 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -38,7 +38,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O }() private(set) lazy var documentPickerController: UIDocumentPickerViewController = { - let documentPickerController = UIDocumentPickerViewController(documentTypes: ["public.image"], in: .open) + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) documentPickerController.delegate = self return documentPickerController }() @@ -500,6 +500,21 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) + viewModel.avatarImage + .receive(on: DispatchQueue.main) + .sink{ [weak self] image in + guard let self = self else { return } + self.avatarButton.menu = self.createMediaContextMenu() + if let avatar = image { + self.avatarButton.setImage(avatar, for: .normal) + } else { + let boldFont = UIFont.systemFont(ofSize: 42) + let configuration = UIImage.SymbolConfiguration(font: boldFont) + let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) + self.avatarButton.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) + } + } + .store(in: &disposeBag) NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: usernameTextField) .receive(on: DispatchQueue.main) From 9ddd8365d0291d273fa77a53e3d8d7211da9d222 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 14:28:40 +0800 Subject: [PATCH 151/400] feature: add search API --- Mastodon.xcodeproj/project.pbxproj | 143 +++++++++--------- .../Scene/Search/SearchViewController.swift | 6 +- Mastodon/Scene/Search/SearchViewModel.swift | 21 +++ .../MastodonSDK/API/Mastodon+API+Search.swift | 96 ++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+SearchResult.swift | 16 ++ 6 files changed, 214 insertions(+), 69 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchViewModel.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 61204352a..d993405d3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -42,7 +42,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1FD25CD481700561493 /* StatusProvider.swift */; }; 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; - 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */; }; + 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; @@ -52,18 +52,19 @@ 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; - 2D5981BA25E4D7F8000FB903 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */; }; + 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */ = {isa = PBXBuildFile; productRef = 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */; }; 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D0225CF8742002347D6 /* ControlContainableScrollViews.swift */; }; 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; - 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */; }; + 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */; }; 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */; }; 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */; }; 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */; }; + 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; @@ -81,7 +82,7 @@ 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F1325C7EDD9004F19B8 /* Emoji.swift */; }; 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; - 2D939AC825EE14620076FA61 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */; }; + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; @@ -96,6 +97,9 @@ 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */; }; + 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; + 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; + 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; @@ -108,7 +112,7 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; - DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */; }; + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; @@ -126,7 +130,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */; }; + DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; @@ -163,7 +167,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A63C25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift */; }; DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */; }; DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; - DB5086B825CC0D6400C2C187 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */; }; + DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; @@ -229,7 +233,7 @@ DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98339B25C96DE600AD9700 /* APIService+Account.swift */; }; DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; - DB9A487E2603456B008B817C /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* SwiftPackageProductDependency */; }; + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; @@ -252,8 +256,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; - DBE64A8B260C49D200E6359A /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */; }; - DBE64A8C260C49D200E6359A /* BuildFile in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; + DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -301,7 +305,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - DBE64A8C260C49D200E6359A /* BuildFile in Embed Frameworks */, + DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); name = "Embed Frameworks"; @@ -363,6 +367,7 @@ 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningOverlayView.swift; sourceTree = ""; }; 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Status.swift"; sourceTree = ""; }; + 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; @@ -572,17 +577,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DB0140BD25C40D7500F9F3CF /* BuildFile in Frameworks */, + DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, - 2D42FF6125C8177C004A627A /* BuildFile in Frameworks */, - DB9A487E2603456B008B817C /* BuildFile in Frameworks */, - 2D939AC825EE14620076FA61 /* BuildFile in Frameworks */, - 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */, - DB5086B825CC0D6400C2C187 /* BuildFile in Frameworks */, - 2D61336925C18A4F00CAE157 /* BuildFile in Frameworks */, - DB3D0FF325BAA61700EAA174 /* BuildFile in Frameworks */, - DBE64A8B260C49D200E6359A /* BuildFile in Frameworks */, - 2D5981BA25E4D7F8000FB903 /* BuildFile in Frameworks */, + 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, + DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, + 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, + 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, + 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, + DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, + DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, + 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1382,6 +1387,7 @@ isa = PBXGroup; children = ( DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, + 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, ); path = Search; sourceTree = ""; @@ -1492,16 +1498,16 @@ ); name = Mastodon; packageProductDependencies = ( - DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */, - 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */, - 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */, - 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */, - DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */, - DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */, - 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */, - 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */, - DB9A487D2603456B008B817C /* SwiftPackageProductDependency */, - DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */, + DB3D0FF225BAA61700EAA174 /* AlamofireImage */, + 5D526FE125BE9AC400460CB9 /* MastodonSDK */, + 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */, + 2D42FF6025C8177C004A627A /* ActiveLabel */, + DB0140BC25C40D7500F9F3CF /* CommonOSLog */, + DB5086B725CC0D6400C2C187 /* Kingfisher */, + 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, + 2D939AC725EE14620076FA61 /* CropViewController */, + DB9A487D2603456B008B817C /* UITextView+Placeholder */, + DBE64A8A260C49D200E6359A /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1624,15 +1630,15 @@ ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( - DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */, - 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */, - 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */, - DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */, - DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */, - 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */, - 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */, - DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */, - DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */, + DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */, + 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */, + 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */, + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */, + DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */, + 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, + DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2011,6 +2017,7 @@ 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */, ); @@ -2543,7 +2550,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */ = { + 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { @@ -2551,7 +2558,7 @@ minimumVersion = 4.0.0; }; }; - 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */ = { + 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/vtourraine/ThirdPartyMailer.git"; requirement = { @@ -2559,7 +2566,7 @@ minimumVersion = 1.7.1; }; }; - 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */ = { + 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireNetworkActivityIndicator"; requirement = { @@ -2567,7 +2574,7 @@ minimumVersion = 3.1.0; }; }; - 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */ = { + 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TimOliver/TOCropViewController.git"; requirement = { @@ -2575,7 +2582,7 @@ minimumVersion = 2.6.0; }; }; - DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */ = { + DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/CommonOSLog"; requirement = { @@ -2583,7 +2590,7 @@ minimumVersion = 0.1.1; }; }; - DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */ = { + DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; requirement = { @@ -2591,7 +2598,7 @@ minimumVersion = 4.1.0; }; }; - DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */ = { + DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/onevcat/Kingfisher.git"; requirement = { @@ -2599,7 +2606,7 @@ minimumVersion = 6.1.0; }; }; - DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */ = { + DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; requirement = { @@ -2607,7 +2614,7 @@ minimumVersion = 1.4.1; }; }; - DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */ = { + DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; requirement = { @@ -2618,53 +2625,53 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 2D42FF6025C8177C004A627A /* SwiftPackageProductDependency */ = { + 2D42FF6025C8177C004A627A /* ActiveLabel */ = { isa = XCSwiftPackageProductDependency; - package = 2D42FF5F25C8177C004A627A /* RemoteSwiftPackageReference */; + package = 2D42FF5F25C8177C004A627A /* XCRemoteSwiftPackageReference "ActiveLabel" */; productName = ActiveLabel; }; - 2D5981B925E4D7F8000FB903 /* SwiftPackageProductDependency */ = { + 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */ = { isa = XCSwiftPackageProductDependency; - package = 2D5981B825E4D7F8000FB903 /* RemoteSwiftPackageReference */; + package = 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */; productName = ThirdPartyMailer; }; - 2D61336825C18A4F00CAE157 /* SwiftPackageProductDependency */ = { + 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */ = { isa = XCSwiftPackageProductDependency; - package = 2D61336725C18A4F00CAE157 /* RemoteSwiftPackageReference */; + package = 2D61336725C18A4F00CAE157 /* XCRemoteSwiftPackageReference "AlamofireNetworkActivityIndicator" */; productName = AlamofireNetworkActivityIndicator; }; - 2D939AC725EE14620076FA61 /* SwiftPackageProductDependency */ = { + 2D939AC725EE14620076FA61 /* CropViewController */ = { isa = XCSwiftPackageProductDependency; - package = 2D939AC625EE14620076FA61 /* RemoteSwiftPackageReference */; + package = 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */; productName = CropViewController; }; - 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */ = { + 5D526FE125BE9AC400460CB9 /* MastodonSDK */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; - DB0140BC25C40D7500F9F3CF /* SwiftPackageProductDependency */ = { + DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; - package = DB0140BB25C40D7500F9F3CF /* RemoteSwiftPackageReference */; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; - DB3D0FF225BAA61700EAA174 /* SwiftPackageProductDependency */ = { + DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; - package = DB3D0FF125BAA61700EAA174 /* RemoteSwiftPackageReference */; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; productName = AlamofireImage; }; - DB5086B725CC0D6400C2C187 /* SwiftPackageProductDependency */ = { + DB5086B725CC0D6400C2C187 /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; - package = DB5086B625CC0D6400C2C187 /* RemoteSwiftPackageReference */; + package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; - DB9A487D2603456B008B817C /* SwiftPackageProductDependency */ = { + DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; - package = DB9A487C2603456B008B817C /* RemoteSwiftPackageReference */; + package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; - DBE64A8A260C49D200E6359A /* SwiftPackageProductDependency */ = { + DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { isa = XCSwiftPackageProductDependency; - package = DBE64A89260C49D200E6359A /* RemoteSwiftPackageReference */; + package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 084e7b231..acf2759fa 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -2,16 +2,20 @@ // SearchViewController.swift // Mastodon // -// Created by MainasuK Cirno on 2021-2-23. +// Created by sxiaojian on 2021/3/31. // import UIKit +import Combine final class SearchViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + private(set) lazy var viewModel = SearchViewModel(context: context) + } extension SearchViewController { diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift new file mode 100644 index 000000000..40eddb965 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -0,0 +1,21 @@ +// +// SearchViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import Combine + +final class SearchViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + + init(context: AppContext) { + self.context = context + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift new file mode 100644 index 000000000..bfb7b0566 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -0,0 +1,96 @@ +// +// Mastodon+API+Search.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import Combine + +/// Search results +/// +/// Search for content in accounts, statuses and hashtags. +/// +/// Version history: +/// 2.4.1 - added, limit hardcoded to 5 +/// 2.8.0 - add type, limit, offset, min_id, max_id, account_id +/// 3.0.0 - add exclude_unreviewed param +/// # Reference +/// [Document](https://docs.joinmastodon.org/methods/search/) +/// - Parameters: +/// - session: `URLSession` +/// - domain: Mastodon instance domain. e.g. "example.com" +/// - statusID: id for status +/// - authorization: User token. Could be nil if status is public +/// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response + +extension Mastodon.API.Search { + + static func searchURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v2/search") + } + + public static func search( + session: URLSession, + domain: String, + query: Query + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: searchURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.SearchResult].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Search { + public struct Query: Codable, GetQuery { + public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { + self.accountID = accountID + self.maxID = maxID + self.minID = minID + self.type = type + self.excludeUnreviewed = excludeUnreviewed + self.q = q + self.resolve = resolve + self.limit = limit + self.offset = offset + self.following = following + } + + public let accountID: Mastodon.Entity.Account.ID? + public let maxID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let type: String? + public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. + public let q: String + public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false. + public let limit: Int? // Maximum number of results to load, per type. Defaults to 20. Max 40. + public let offset: Int? // Offset in search results. Used for pagination. Defaults to 0. + public let following: Bool? // Only include accounts that the user is following. Defaults to false. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + accountID.flatMap{ items.append(URLQueryItem(name: "account_id", value: $0)) } + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) } + excludeUnreviewed.flatMap{ items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } + items.append(URLQueryItem(name: "q", value: q)) + resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } + + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) } + following.flatMap { items.append(URLQueryItem(name: "following", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index b86cc50e8..239031cd1 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -101,6 +101,7 @@ extension Mastodon.API { public enum Reblog { } public enum Statuses { } public enum Timeline { } + public enum Search { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift new file mode 100644 index 000000000..f06f1a54e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +extension Mastodon.Entity { + public struct SearchResult: Codable { + let accounts: [Mastodon.Entity.Account] + let statuses: [Mastodon.Entity.Status] + let hashtags: [Mastodon.Entity.Tag] + } + +} From 0033ea0680232e8fcf6d080564903cbf9c67bed7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 14:48:34 +0800 Subject: [PATCH 152/400] feature: add trends API --- Mastodon/Scene/Search/SearchViewModel.swift | 6 +- .../MastodonSDK/API/Mastodon+API+Search.swift | 31 +++++---- .../API/Mastodon+API+Suggestions.swift | 8 +++ .../MastodonSDK/API/Mastodon+API+Trends.swift | 65 +++++++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 2 + 5 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 40eddb965..bdb7e0a22 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -7,13 +7,17 @@ import Foundation import Combine +import MastodonSDK +import UIKit final class SearchViewModel { var disposeBag = Set() - // input + let context: AppContext + // input + let username = CurrentValueSubject("") init(context: AppContext) { self.context = context diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index bfb7b0566..114ca27b8 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -1,12 +1,12 @@ // // Mastodon+API+Search.swift -// +// // // Created by sxiaojian on 2021/3/31. // -import Foundation import Combine +import Foundation /// Search results /// @@ -21,29 +21,28 @@ import Combine /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" -/// - statusID: id for status -/// - authorization: User token. Could be nil if status is public +/// - query: search query /// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response extension Mastodon.API.Search { - static func searchURL(domain: String) -> URL { - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v2/search") + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v2/search") } - + public static func search( session: URLSession, domain: String, - query: Query - ) -> AnyPublisher, Error> { + query: Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: searchURL(domain: domain), query: query, - authorization: nil + authorization: authorization ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: [Mastodon.Entity.SearchResult].self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.SearchResult.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() @@ -64,7 +63,7 @@ extension Mastodon.API.Search { self.offset = offset self.following = following } - + public let accountID: Mastodon.Entity.Account.ID? public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? @@ -75,17 +74,17 @@ extension Mastodon.API.Search { public let limit: Int? // Maximum number of results to load, per type. Defaults to 20. Max 40. public let offset: Int? // Offset in search results. Used for pagination. Defaults to 0. public let following: Bool? // Only include accounts that the user is following. Defaults to false. - + var queryItems: [URLQueryItem]? { var items: [URLQueryItem] = [] - accountID.flatMap{ items.append(URLQueryItem(name: "account_id", value: $0)) } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) } - excludeUnreviewed.flatMap{ items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } + excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } items.append(URLQueryItem(name: "q", value: q)) resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } - + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) } following.flatMap { items.append(URLQueryItem(name: "following", value: $0.queryItemValue)) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift new file mode 100644 index 000000000..bc2adaee4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift new file mode 100644 index 000000000..b311cf2b4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -0,0 +1,65 @@ +// +// Mastodon+API+Trends.swift +// +// +// Created by sxiaojian on 2021/3/31. +// + +import Combine +import Foundation + +/// Trending tags +/// +/// Tags that are being used more frequently within the past week. +/// +/// Version history: +/// 3.0.0 - added +/// # Reference +/// [Document](https://docs.joinmastodon.org/methods/instance/trends/) +/// - Parameters: +/// - session: `URLSession` +/// - domain: Mastodon instance domain. e.g. "example.com" +/// - query: query +/// - authorization: User token. +/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response + +extension Mastodon.API.Trends { + static func trendsURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("/api/v1/trends") + } + + public static func get( + session: URLSession, + domain: String, + query: Query + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: trendsURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Tag].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Trends { + public struct Query: Codable, GetQuery { + public init(limit: Int?) { + self.limit = limit + } + + public let limit: Int? // Maximum number of results to return. Defaults to 10. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 239031cd1..644c86b91 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -102,6 +102,8 @@ extension Mastodon.API { public enum Statuses { } public enum Timeline { } public enum Search { } + public enum Trends { } + public enum Suggestions { } } extension Mastodon.API { From 5ec07e617e81ab4595307394e253876306be26ab Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 15:06:46 +0800 Subject: [PATCH 153/400] feature: add Suggestions API --- .../Scene/Search/SearchViewController.swift | 6 +- .../MastodonSDK/API/Mastodon+API+Search.swift | 2 +- .../API/Mastodon+API+Suggestions.swift | 60 ++++++++++++++++++- .../MastodonSDK/API/Mastodon+API+Trends.swift | 4 +- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index acf2759fa..426120d54 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -16,13 +16,17 @@ final class SearchViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = SearchViewModel(context: context) + let searchBar: UISearchBar = { + let searchBar = UISearchBar() + return searchBar + }() } extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() - + } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 114ca27b8..b157d0938 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -32,7 +32,7 @@ extension Mastodon.API.Search { public static func search( session: URLSession, domain: String, - query: Query, + query: Mastodon.API.Search.Query, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift index bc2adaee4..ddb2297d8 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -1,8 +1,66 @@ // -// File.swift +// Mastodon+API+Suggestions.swift // // // Created by sxiaojian on 2021/3/31. // +import Combine import Foundation + +/// Follow suggestions +/// +/// Server-generated suggestions on who to follow, based on previous positive interactions. +/// +/// Version history: +/// 2.4.3 - added +/// # Reference +/// [Document](https://docs.joinmastodon.org/methods/accounts/suggestions/) +/// - Parameters: +/// - session: `URLSession` +/// - domain: Mastodon instance domain. e.g. "example.com" +/// - query: query +/// - authorization: User token. +/// - Returns: `AnyPublisher` contains `Accounts` nested in the response + +extension Mastodon.API.Suggestions { + static func suggestionsURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/suggestions") + } + + public static func get( + session: URLSession, + domain: String, + query: Mastodon.API.Suggestions.Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: suggestionsURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Suggestions { + public struct Query: Codable, GetQuery { + public init(limit: Int?) { + self.limit = limit + } + + public let limit: Int? // Maximum number of results to return. Defaults to 40. + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index b311cf2b4..3c7e8515a 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -25,13 +25,13 @@ import Foundation extension Mastodon.API.Trends { static func trendsURL(domain: String) -> URL { - Mastodon.API.endpointURL(domain: domain).appendingPathComponent("/api/v1/trends") + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/trends") } public static func get( session: URLSession, domain: String, - query: Query + query: Mastodon.API.Trends.Query ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: trendsURL(domain: domain), From 09320bf99c585dc6e17482ee370fcb19ade728f1 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 19:29:54 +0800 Subject: [PATCH 154/400] chore: add api to APIService --- Localization/app.json | 8 ++- Mastodon.xcodeproj/project.pbxproj | 15 ++++-- Mastodon/Generated/Strings.swift | 8 +++ .../Resources/en.lproj/Localizable.strings | 2 + .../SearchViewController+recomendView.swift | 33 ++++++++++++ .../Scene/Search/SearchViewController.swift | 50 ++++++++++++++++++- Mastodon/Scene/Search/SearchViewModel.swift | 11 ++-- .../APIService/APIService+Recommend.swift | 30 +++++++++++ .../APIService/APIService+Search.swift | 23 +++++++++ 9 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchViewController+recomendView.swift create mode 100644 Mastodon/Service/APIService/APIService+Recommend.swift create mode 100644 Mastodon/Service/APIService/APIService+Search.swift diff --git a/Localization/app.json b/Localization/app.json index 99868a8fb..6fcd944c7 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -231,6 +231,12 @@ "private": "Followers only", "direct": "Only people I mention" } + }, + "search": { + "searchBar": { + "placeholder": "Search hashtags and users", + "cancel": "Cancel" + } } } -} \ No newline at end of file +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d993405d3..fb7b643e6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */; }; + 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; + 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; }; 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; }; 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; @@ -96,9 +99,6 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; - 5D526FE225BE9AC400460CB9 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* SwiftPackageProductDependency */; }; - 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; - 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; @@ -336,6 +336,9 @@ 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+recomendView.swift"; sourceTree = ""; }; + 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; + 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = ""; }; 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; @@ -1102,6 +1105,8 @@ DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, + 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, + 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, ); path = APIService; sourceTree = ""; @@ -1387,6 +1392,7 @@ isa = PBXGroup; children = ( DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, ); path = Search; @@ -1869,6 +1875,7 @@ DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, + 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, @@ -1929,6 +1936,7 @@ DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, + 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, @@ -1959,6 +1967,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 82fa696d8..6aad8de04 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -365,6 +365,14 @@ internal enum L10n { } } } + internal enum Search { + internal enum Searchbar { + /// Cancel + internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel") + /// Search hashtags and users + internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder") + } + } internal enum ServerPicker { /// Pick a Server,\nany server. internal static let title = L10n.tr("Localizable", "Scene.ServerPicker.Title") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c2bc09c68..4b3e0aa17 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -114,6 +114,8 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Search.Searchbar.Cancel" = "Cancel"; +"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.SeeLess" = "See Less"; "Scene.ServerPicker.Button.SeeMore" = "See More"; diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+recomendView.swift new file mode 100644 index 000000000..f3783ddc9 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+recomendView.swift @@ -0,0 +1,33 @@ +// +// SearchViewController+recomemndView.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import UIKit + + +extension SearchViewController { + func setuprecomemndView() { + recomemndView.dataSource = self + recomemndView.delegate = self + } +} + +extension SearchViewController: UICollectionViewDelegate { + +} + +extension SearchViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + return UICollectionViewCell() + } + + +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 426120d54..5533e2ef0 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -18,15 +18,63 @@ final class SearchViewController: UIViewController, NeedsDependency { let searchBar: UISearchBar = { let searchBar = UISearchBar() + searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder + searchBar.tintColor = Asset.Colors.buttonDefault.color + searchBar.translatesAutoresizingMaskIntoConstraints = false + let micImage = UIImage(systemName: "mic.fill") + searchBar.setImage(micImage, for: .bookmark, state: .normal) + searchBar.showsBookmarkButton = true return searchBar }() + + let recomemndView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() } extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() - + searchBar.delegate = self + navigationItem.titleView = searchBar + navigationItem.hidesBackButton = true } } + +extension SearchViewController: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(true, animated: true) + } + + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.setShowsCancelButton(false, animated: true) + searchBar.text = "" + searchBar.resignFirstResponder() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.searchText.send(searchText) + } + + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) { + + } +} + +extension SearchViewController { + +} diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index bdb7e0a22..bb2c33ecb 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -14,12 +14,17 @@ final class SearchViewModel { var disposeBag = Set() - - let context: AppContext // input - let username = CurrentValueSubject("") + let context: AppContext + + // output + let searchText = CurrentValueSubject("") + + var recommendHashTags = [Mastodon.Entity.Tag]() + var recommendAccounts = [Mastodon.Entity.Account]() init(context: AppContext) { self.context = context + } } diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift new file mode 100644 index 000000000..dd22ede00 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -0,0 +1,30 @@ +// +// APIService+Recommend.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func recommendAccount( + domain: String, + query: Mastodon.API.Suggestions.Query, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + } + + func recommendTrends( + domain: String, + query: Mastodon.API.Trends.Query + ) -> AnyPublisher, Error> { + return Mastodon.API.Trends.get(session: session, domain: domain, query: query) + } +} diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift new file mode 100644 index 000000000..ba40aa5de --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Search.swift @@ -0,0 +1,23 @@ +// +// APIService+Search.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func search( + domain: String, + query: Mastodon.API.Search.Query, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization) + } +} From dff874af76b2722aec8bfcfb04c373adf7795d60 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 31 Mar 2021 20:56:11 +0800 Subject: [PATCH 155/400] feature: add SearchRecommendTagsCollectionViewCell --- Mastodon.xcodeproj/project.pbxproj | 16 ++ Mastodon/Extension/UIView+Constraint.swift | 257 ++++++++++++++++++ ...earchRecommendTagsCollectionViewCell.swift | 76 ++++++ .../SearchViewController+recomendView.swift | 26 +- .../Scene/Search/SearchViewController.swift | 2 +- Mastodon/Scene/Search/SearchViewModel.swift | 36 ++- .../APIService/APIService+Recommend.swift | 4 +- .../API/Mastodon+API+Suggestions.swift | 2 +- .../MastodonSDK/API/Mastodon+API+Trends.swift | 2 +- 9 files changed, 410 insertions(+), 11 deletions(-) create mode 100644 Mastodon/Extension/UIView+Constraint.swift create mode 100644 Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index fb7b643e6..5179c3870 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; + 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; }; 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; }; 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; @@ -99,6 +100,7 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; + 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; @@ -339,6 +341,7 @@ 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+recomendView.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; + 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = ""; }; 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; @@ -402,6 +405,7 @@ 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; }; 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; }; 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; + 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = ""; }; 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = ""; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -706,6 +710,14 @@ path = Content; sourceTree = ""; }; + 2D34D9E026149C550081BFC0 /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { isa = PBXGroup; children = ( @@ -1352,6 +1364,7 @@ 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, + 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, @@ -1394,6 +1407,7 @@ DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, + 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); path = Search; sourceTree = ""; @@ -1986,6 +2000,7 @@ DB98338725C945ED00AD9700 /* Strings.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, + 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, @@ -1995,6 +2010,7 @@ DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, + 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift new file mode 100644 index 000000000..40489629c --- /dev/null +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -0,0 +1,257 @@ +// +// UIView+Constraint.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import UIKit + +enum Dimension { + case width + case height + + var layoutAttribute: NSLayoutConstraint.Attribute { + switch self { + case .width: + return .width + case .height: + return .height + } + } + +} + +extension UIView { + + func constrain(toSuperviewEdges: UIEdgeInsets?) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return} + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + NSLayoutConstraint(item: self, + attribute: .leading, + relatedBy: .equal, + toItem: view, + attribute: .leading, + multiplier: 1.0, + constant: toSuperviewEdges?.left ?? 0.0), + NSLayoutConstraint(item: self, + attribute: .top, + relatedBy: .equal, + toItem: view, + attribute: .top, + multiplier: 1.0, + constant: toSuperviewEdges?.top ?? 0.0), + NSLayoutConstraint(item: self, + attribute: .trailing, + relatedBy: .equal, + toItem: view, + attribute: .trailing, + multiplier: 1.0, + constant: toSuperviewEdges?.right ?? 0.0), + NSLayoutConstraint(item: self, + attribute: .bottom, + relatedBy: .equal, + toItem: view, + attribute: .bottom, + multiplier: 1.0, + constant: toSuperviewEdges?.bottom ?? 0.0) + ]) + } + + func constrain(_ constraints: [NSLayoutConstraint?]) { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate(constraints.compactMap { $0 }) + } + + func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0) + } + + func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0) + } + + func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, + attribute: dimension.layoutAttribute, + relatedBy: .equal, + toItem: nil, + attribute: .notAnAttribute, + multiplier: 1.0, + constant: constant) + } + + func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant) + } + + func pinToBottom(to: UIView, height: CGFloat) { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.width, toView: to), + constraint(toBottom: to, constant: 0.0), + constraint(.height, constant: height) + ]) + } + + func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant) + } + + func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant) + } + + func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } + translatesAutoresizingMaskIntoConstraints = false + return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant) + } + + func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding), + constraint(.trailing, toView: view, constant: -sidePadding) + ]) + } + + func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + constraint(.top, toView: view, constant: topPadding), + constraint(.trailing, toView: view, constant: -sidePadding) + ]) + } + + func constrainTopCorners(height: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view), + constraint(.top, toView: view), + constraint(.trailing, toView: view), + constraint(.height, constant: height) + ]) + } + + func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view, constant: sidePadding), + constraint(.bottom, toView: view, constant: -bottomPadding), + constraint(.trailing, toView: view, constant: -sidePadding) + ]) + } + + func constrainBottomCorners(height: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.leading, toView: view), + constraint(.bottom, toView: view), + constraint(.trailing, toView: view), + constraint(.height, constant: height) + ]) + } + + func constrainLeadingCorners() { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.top, toView: view), + constraint(.leading, toView: view), + constraint(.bottom, toView: view) + ]) + } + + func constrainTrailingCorners() { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.top, toView: view), + constraint(.trailing, toView: view), + constraint(.bottom, toView: view) + ]) + } + + func constrainToCenter() { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.centerX, toView: view), + constraint(.centerY, toView: view) + ]) + } + + func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + constraint(.width, toView: viewAbove), + constraint(toBottom: viewAbove, constant: padding), + self.centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor), + height != nil ? constraint(.height, constant: height!) : nil + ]) + } + + func pin(toSize: CGSize) { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + widthAnchor.constraint(equalToConstant: toSize.width), + heightAnchor.constraint(equalToConstant: toSize.height)]) + } + + func pinTopLeft(padding: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), + topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) + } + + func pinTopRight(padding: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), + topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) + } + + func pinTopLeft(toView: UIView, topPadding: CGFloat) { + guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + constrain([ + leadingAnchor.constraint(equalTo: toView.leadingAnchor), + topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)]) + } + + /// Cross-fades between two views by animating their alpha then setting one or the other hidden. + /// - parameters: + /// - lhs: left view + /// - rhs: right view + /// - toRight: fade to the right view if true, fade to the left view if false + /// - duration: animation duration + /// + static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) { + lhs.alpha = toRight ? 1.0 : 0.0 + rhs.alpha = toRight ? 0.0 : 1.0 + lhs.isHidden = false + rhs.isHidden = false + + UIView.animate(withDuration: duration, animations: { + lhs.alpha = toRight ? 0.0 : 1.0 + rhs.alpha = toRight ? 1.0 : 0.0 + }, completion: { _ in + lhs.isHidden = toRight + rhs.isHidden = !toRight + }) + } +} diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift new file mode 100644 index 000000000..108f2b6a2 --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -0,0 +1,76 @@ +// +// SearchRecommendTagsCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/3/31. +// + +import Foundation +import UIKit + +class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { + let backgroundImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + let hashTagTitleLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .preferredFont(forTextStyle: .caption1) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let peopleLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .preferredFont(forTextStyle: .body) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let flameIconView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate) + imageView.image = image + imageView.tintColor = .white + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendTagsCollectionViewCell { + private func configure() { + contentView.addSubview(backgroundImageView) + backgroundImageView.constrain(toSuperviewEdges: nil) + + contentView.addSubview(hashTagTitleLabel) + hashTagTitleLabel.pinTopLeft(padding: 16) + + contentView.addSubview(peopleLabel) + peopleLabel.constrain([ + peopleLabel.constraint(toTop: contentView, constant: 46), + peopleLabel.constraint(toLeading: contentView, constant: 16) + ]) + + contentView.addSubview(flameIconView) + flameIconView.pinTopRight(padding: 16) + + } +} diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+recomendView.swift index f3783ddc9..b498aa608 100644 --- a/Mastodon/Scene/Search/SearchViewController+recomendView.swift +++ b/Mastodon/Scene/Search/SearchViewController+recomendView.swift @@ -1,5 +1,5 @@ // -// SearchViewController+recomemndView.swift +// SearchViewController+recommendView.swift // Mastodon // // Created by sxiaojian on 2021/3/31. @@ -10,9 +10,14 @@ import UIKit extension SearchViewController { - func setuprecomemndView() { - recomemndView.dataSource = self - recomemndView.delegate = self + func setuprecommendView() { + recommendView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) + recommendView.dataSource = self + recommendView.delegate = self + } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + recommendView.collectionViewLayout.invalidateLayout() } } @@ -21,8 +26,19 @@ extension SearchViewController: UICollectionViewDelegate { } extension SearchViewController: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return (self.viewModel.recommendAccounts.isEmpty ? 0 : 1) + (self.viewModel.recommendHashTags.isEmpty ? 0 : 1) + } + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return 0 + switch section { + case 0: + return viewModel.recommendHashTags.count + case 1: + return viewModel.recommendAccounts.count + default: + return 0 + } } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 5533e2ef0..8d6139e17 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -27,7 +27,7 @@ final class SearchViewController: UIViewController, NeedsDependency { return searchBar }() - let recomemndView: UICollectionView = { + let recommendView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index bb2c33ecb..37e4344cd 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -9,6 +9,7 @@ import Foundation import Combine import MastodonSDK import UIKit +import OSLog final class SearchViewModel { @@ -25,6 +26,39 @@ final class SearchViewModel { init(context: AppContext) { self.context = context - + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + + } receiveValue: { [weak self] tags in + guard let self = self else { return } + self.recommendHashTags = tags.value + } + .store(in: &disposeBag) + + context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + self.recommendAccounts = accounts.value + } + .store(in: &disposeBag) + } } diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index dd22ede00..bf6db0179 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -13,7 +13,7 @@ extension APIService { func recommendAccount( domain: String, - query: Mastodon.API.Suggestions.Query, + query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization @@ -23,7 +23,7 @@ extension APIService { func recommendTrends( domain: String, - query: Mastodon.API.Trends.Query + query: Mastodon.API.Trends.Query? ) -> AnyPublisher, Error> { return Mastodon.API.Trends.get(session: session, domain: domain, query: query) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift index ddb2297d8..5cabe833e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -31,7 +31,7 @@ extension Mastodon.API.Suggestions { public static func get( session: URLSession, domain: String, - query: Mastodon.API.Suggestions.Query, + query: Mastodon.API.Suggestions.Query?, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index 3c7e8515a..a377439fe 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -31,7 +31,7 @@ extension Mastodon.API.Trends { public static func get( session: URLSession, domain: String, - query: Mastodon.API.Trends.Query + query: Mastodon.API.Trends.Query? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: trendsURL(domain: domain), From fde5baad2e8358dd796caa92af82dde178035188 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 1 Apr 2021 10:38:39 +0800 Subject: [PATCH 156/400] chore: add translatesAutoresizingMaskIntoConstraints = false to all constrain method --- Mastodon/Extension/UIView+Constraint.swift | 14 +++++++++ .../API/Mastodon+API+Suggestions.swift | 29 +++++++++---------- .../MastodonSDK/API/Mastodon+API+Trends.swift | 29 +++++++++---------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift index 40489629c..42d3bfd93 100644 --- a/Mastodon/Extension/UIView+Constraint.swift +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -97,6 +97,7 @@ extension UIView { func pinToBottom(to: UIView, height: CGFloat) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.width, toView: to), constraint(toBottom: to, constant: 0.0), @@ -124,6 +125,7 @@ extension UIView { func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.leading, toView: view, constant: sidePadding), NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding), @@ -133,6 +135,7 @@ extension UIView { func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.leading, toView: view, constant: sidePadding), constraint(.top, toView: view, constant: topPadding), @@ -142,6 +145,7 @@ extension UIView { func constrainTopCorners(height: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.leading, toView: view), constraint(.top, toView: view), @@ -152,6 +156,7 @@ extension UIView { func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.leading, toView: view, constant: sidePadding), constraint(.bottom, toView: view, constant: -bottomPadding), @@ -161,6 +166,7 @@ extension UIView { func constrainBottomCorners(height: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.leading, toView: view), constraint(.bottom, toView: view), @@ -171,6 +177,7 @@ extension UIView { func constrainLeadingCorners() { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.top, toView: view), constraint(.leading, toView: view), @@ -180,6 +187,7 @@ extension UIView { func constrainTrailingCorners() { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.top, toView: view), constraint(.trailing, toView: view), @@ -189,6 +197,7 @@ extension UIView { func constrainToCenter() { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.centerX, toView: view), constraint(.centerY, toView: view) @@ -197,6 +206,7 @@ extension UIView { func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ constraint(.width, toView: viewAbove), constraint(toBottom: viewAbove, constant: padding), @@ -207,6 +217,7 @@ extension UIView { func pin(toSize: CGSize) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ widthAnchor.constraint(equalToConstant: toSize.width), heightAnchor.constraint(equalToConstant: toSize.height)]) @@ -214,6 +225,7 @@ extension UIView { func pinTopLeft(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) @@ -221,6 +233,7 @@ extension UIView { func pinTopRight(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) @@ -228,6 +241,7 @@ extension UIView { func pinTopLeft(toView: UIView, topPadding: CGFloat) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false constrain([ leadingAnchor.constraint(equalTo: toView.leadingAnchor), topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)]) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift index 5cabe833e..5a966afbd 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -8,26 +8,25 @@ import Combine import Foundation -/// Follow suggestions -/// -/// Server-generated suggestions on who to follow, based on previous positive interactions. -/// -/// Version history: -/// 2.4.3 - added -/// # Reference -/// [Document](https://docs.joinmastodon.org/methods/accounts/suggestions/) -/// - Parameters: -/// - session: `URLSession` -/// - domain: Mastodon instance domain. e.g. "example.com" -/// - query: query -/// - authorization: User token. -/// - Returns: `AnyPublisher` contains `Accounts` nested in the response - extension Mastodon.API.Suggestions { static func suggestionsURL(domain: String) -> URL { Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/suggestions") } + /// Follow suggestions + /// + /// Server-generated suggestions on who to follow, based on previous positive interactions. + /// + /// Version history: + /// 2.4.3 - added + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/suggestions/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Accounts` nested in the response public static func get( session: URLSession, domain: String, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index a377439fe..e0f316434 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -8,26 +8,25 @@ import Combine import Foundation -/// Trending tags -/// -/// Tags that are being used more frequently within the past week. -/// -/// Version history: -/// 3.0.0 - added -/// # Reference -/// [Document](https://docs.joinmastodon.org/methods/instance/trends/) -/// - Parameters: -/// - session: `URLSession` -/// - domain: Mastodon instance domain. e.g. "example.com" -/// - query: query -/// - authorization: User token. -/// - Returns: `AnyPublisher` contains `Hashtags` nested in the response - extension Mastodon.API.Trends { static func trendsURL(domain: String) -> URL { Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/trends") } + /// Trending tags + /// + /// Tags that are being used more frequently within the past week. + /// + /// Version history: + /// 3.0.0 - added + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/trends/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - Returns: `AnyPublisher` contains `Hashtags` nested in the response + public static func get( session: URLSession, domain: String, From ada6d542f3efb6c5f01d7b376e0df432a7ae9ba3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 1 Apr 2021 11:49:38 +0800 Subject: [PATCH 157/400] fix: decode error --- .../CoreData.xcdatamodel/contents | 6 +- CoreDataStack/Entity/History.swift | 10 ++-- .../Scene/Search/SearchViewController.swift | 1 + Mastodon/Scene/Search/SearchViewModel.swift | 59 ++++++++++++------- .../MastodonSDK/API/Mastodon+API+Search.swift | 34 +++++------ .../API/Mastodon+API+Suggestions.swift | 2 +- .../MastodonSDK/API/Mastodon+API+Trends.swift | 2 +- .../MastodonSDK/API/Mastodon+API.swift | 3 + .../Entity/Mastodon+Entity+History.swift | 4 +- 9 files changed, 71 insertions(+), 50 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index c9ebac45f..3c73a77bf 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -35,11 +35,11 @@ - + - + diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 552e2a406..114879298 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -14,8 +14,8 @@ public final class History: NSManagedObject { @NSManaged public private(set) var createAt: Date @NSManaged public private(set) var day: Date - @NSManaged public private(set) var uses: Int - @NSManaged public private(set) var accounts: Int + @NSManaged public private(set) var uses: String + @NSManaged public private(set) var accounts: String // many-to-one relationship @NSManaged public private(set) var tag: Tag @@ -43,10 +43,10 @@ public extension History { public extension History { struct Property { public let day: Date - public let uses: Int - public let accounts: Int + public let uses: String + public let accounts: String - public init(day: Date, uses: Int, accounts: Int) { + public init(day: Date, uses: String, accounts: String) { self.day = day self.uses = uses self.accounts = accounts diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 8d6139e17..f76f596c0 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -47,6 +47,7 @@ extension SearchViewController { searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true + viewModel.requestRecommendData() } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 37e4344cd..160dc069d 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -26,39 +26,56 @@ final class SearchViewModel { init(context: AppContext) { self.context = context + } + + func requestRecommendData() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) + let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) +// .sink { completion in +// switch completion { +// case .failure(let error): +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// case .finished: +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends success", ((#file as NSString).lastPathComponent), #line, #function) +// break +// } +// +// } receiveValue: { [weak self] tags in +// guard let self = self else { return } +// self.recommendHashTags = tags.value +// } +// .store(in: &disposeBag) + + let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) +// .sink { completion in +// switch completion { +// case .failure(let error): +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// case .finished: +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount success", ((#file as NSString).lastPathComponent), #line, #function) +// break +// } +// } receiveValue: { [weak self] accounts in +// guard let self = self else { return } +// self.recommendAccounts = accounts.value +// } +// .store(in: &disposeBag) + Publishers.Zip(trendsAPI,accountsAPI) .sink { completion in switch completion { case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends success", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request success", ((#file as NSString).lastPathComponent), #line, #function) break } - - } receiveValue: { [weak self] tags in + } receiveValue: { [weak self] (tags, accounts) in guard let self = self else { return } + self.recommendAccounts = accounts.value self.recommendHashTags = tags.value } .store(in: &disposeBag) - - context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount success", ((#file as NSString).lastPathComponent), #line, #function) - break - } - } receiveValue: { [weak self] accounts in - guard let self = self else { return } - self.recommendAccounts = accounts.value - } - .store(in: &disposeBag) - } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index b157d0938..8f266437f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -8,27 +8,27 @@ import Combine import Foundation -/// Search results -/// -/// Search for content in accounts, statuses and hashtags. -/// -/// Version history: -/// 2.4.1 - added, limit hardcoded to 5 -/// 2.8.0 - add type, limit, offset, min_id, max_id, account_id -/// 3.0.0 - add exclude_unreviewed param -/// # Reference -/// [Document](https://docs.joinmastodon.org/methods/search/) -/// - Parameters: -/// - session: `URLSession` -/// - domain: Mastodon instance domain. e.g. "example.com" -/// - query: search query -/// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response - extension Mastodon.API.Search { static func searchURL(domain: String) -> URL { - Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v2/search") + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search") } + /// Search results + /// + /// Search for content in accounts, statuses and hashtags. + /// + /// Version history: + /// 2.4.1 - added, limit hardcoded to 5 + /// 2.8.0 - add type, limit, offset, min_id, max_id, account_id + /// 3.0.0 - add exclude_unreviewed param + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/search/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: search query + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Accounts,Hashtags,Status` nested in the response public static func search( session: URLSession, domain: String, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift index 5a966afbd..558089645 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Suggestions.swift @@ -10,7 +10,7 @@ import Foundation extension Mastodon.API.Suggestions { static func suggestionsURL(domain: String) -> URL { - Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/suggestions") + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("suggestions") } /// Follow suggestions diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index e0f316434..385e3d756 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -10,7 +10,7 @@ import Foundation extension Mastodon.API.Trends { static func trendsURL(domain: String) -> URL { - Mastodon.API.endpointURL(domain: domain).appendingPathComponent("api/v1/trends") + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends") } /// Trending tags diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 644c86b91..ac960e710 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -50,6 +50,9 @@ extension Mastodon.API { if let date = fullDatePreciseISO8601Formatter.date(from: string) { return date } + if let timestamp = TimeInterval(string) { + return Date(timeIntervalSince1970: timestamp) + } } catch { // do nothing } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 9e2f9efb2..9bf1a3a28 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -19,7 +19,7 @@ extension Mastodon.Entity { public struct History: Codable { /// UNIX timestamp on midnight of the given day public let day: Date - public let uses: Int - public let accounts: Int + public let uses: String + public let accounts: String } } From 0f8e3dafe8c58fe5744f4528d0c958b9e185f6ff Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 1 Apr 2021 11:50:35 +0800 Subject: [PATCH 158/400] chore: remove useless code --- Mastodon/Scene/Search/SearchViewModel.swift | 27 --------------------- 1 file changed, 27 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 160dc069d..679a2cfaf 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -33,35 +33,8 @@ final class SearchViewModel { return } let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) -// .sink { completion in -// switch completion { -// case .failure(let error): -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) -// case .finished: -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendTrends success", ((#file as NSString).lastPathComponent), #line, #function) -// break -// } -// -// } receiveValue: { [weak self] tags in -// guard let self = self else { return } -// self.recommendHashTags = tags.value -// } -// .store(in: &disposeBag) let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) -// .sink { completion in -// switch completion { -// case .failure(let error): -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) -// case .finished: -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount success", ((#file as NSString).lastPathComponent), #line, #function) -// break -// } -// } receiveValue: { [weak self] accounts in -// guard let self = self else { return } -// self.recommendAccounts = accounts.value -// } -// .store(in: &disposeBag) Publishers.Zip(trendsAPI,accountsAPI) .sink { completion in switch completion { From 8c3040c0f9ef495ea3fc8eb9d4fdb34eecc1858b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Tue, 30 Mar 2021 14:16:08 +0800 Subject: [PATCH 159/400] feat: add hashtag timeline API --- .../API/Mastodon+API+Timeline.swift | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 03a718b5b..6ab897123 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -16,6 +16,10 @@ extension Mastodon.API.Timeline { static func homeTimelineEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("timelines/home") } + static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("tag/\(hashtag)") + } /// View public timeline statuses /// @@ -81,6 +85,38 @@ extension Mastodon.API.Timeline { .eraseToAnyPublisher() } + /// View public statuses containing the given hashtag. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/29 + /// # Reference + /// [Document](https://https://docs.joinmastodon.org/methods/timelines/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `HashtagTimelineQuery` with query parameters + /// - hashtag: Content of a #hashtag, not including # symbol. + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func hashtag( + session: URLSession, + domain: String, + query: HashtagTimelineQuery, + hashtag: String + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } public protocol TimelineQueryType { @@ -167,4 +203,41 @@ extension Mastodon.API.Timeline { } } + public struct HashtagTimelineQuery: Codable, TimelineQuery, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let local: Bool? + public let onlyMedia: Bool? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + local: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.local = local + self.onlyMedia = onlyMedia + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + local.flatMap { items.append(URLQueryItem(name: "local", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + guard !items.isEmpty else { return nil } + return items + } + } + } From d548840bd913606e612a0eb885396614209a48a8 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Thu, 1 Apr 2021 10:12:57 +0800 Subject: [PATCH 160/400] feat: implement hashtag timeline --- Mastodon.xcodeproj/project.pbxproj | 44 +++ Mastodon/Coordinator/SceneCoordinator.swift | 7 + .../Extension/Array+removeDuplicates.swift | 23 ++ ...Provider+StatusTableViewCellDelegate.swift | 18 ++ Mastodon/Scene/Compose/ComposeViewModel.swift | 22 +- ...imelineViewController+StatusProvider.swift | 88 ++++++ .../HashtagTimelineViewController.swift | 279 ++++++++++++++++++ .../HashtagTimelineViewModel+Diffable.swift | 128 ++++++++ ...tagTimelineViewModel+LoadLatestState.swift | 104 +++++++ ...tagTimelineViewModel+LoadMiddleState.swift | 131 ++++++++ ...tagTimelineViewModel+LoadOldestState.swift | 121 ++++++++ .../HashtagTimelineViewModel.swift | 112 +++++++ .../Scene/Share/View/Content/StatusView.swift | 9 + .../TableviewCell/StatusTableViewCell.swift | 7 + .../APIService+HashtagTimeline.swift | 70 +++++ .../API/Mastodon+API+Timeline.swift | 8 +- 16 files changed, 1165 insertions(+), 6 deletions(-) create mode 100644 Mastodon/Extension/Array+removeDuplicates.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift create mode 100644 Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+HashtagTimeline.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5179c3870..b56a389d9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -316,6 +325,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -634,6 +652,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { + isa = PBXGroup; + children = ( + 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, + 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, + 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, + 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, + 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, + ); + path = HashtagTimeline; + sourceTree = ""; + }; 0FAA0FDD25E0B5700017CCDE /* Welcome */ = { isa = PBXGroup; children = ( @@ -1119,6 +1151,7 @@ DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, + 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, ); path = APIService; sourceTree = ""; @@ -1328,6 +1361,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 0F2021F5261325ED000C64BF /* HashtagTimeline */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, @@ -1369,6 +1403,7 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, ); path = Extension; sourceTree = ""; @@ -1892,6 +1927,7 @@ 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, + 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, @@ -1911,6 +1947,7 @@ 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, + 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, @@ -1941,6 +1978,7 @@ DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, + 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, @@ -1978,6 +2016,7 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, @@ -2005,6 +2044,7 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2012,7 +2052,9 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, @@ -2021,10 +2063,12 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..fb0603251 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -50,6 +50,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // Hashtag Timeline + case hashtagTimeline(viewModel: HashtagTimelineViewModel) + // misc case alertController(alertController: UIAlertController) @@ -206,6 +209,10 @@ private extension SceneCoordinator { ) } viewController = alertController + case .hashtagTimeline(let viewModel): + let _viewController = HashtagTimelineViewController() + _viewController.viewModel = viewModel + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift new file mode 100644 index 000000000..c3a4b0384 --- /dev/null +++ b/Mastodon/Extension/Array+removeDuplicates.swift @@ -0,0 +1,23 @@ +// +// Array+removeDuplicates.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f3d31ff33..1ba6e60cf 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -192,3 +192,21 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } } + +// MARK: - ActiveLabel didSelect ActiveEntity +extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let hashtag, let userInfo): + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: context, hashTag: hashtag) + coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: self, transition: .show) + break + case .email(let content, let userInfo): + break + case .mention(let mention, let userInfo): + break + case .url(let content, let trimmed, let url, let userInfo): + break + } + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52ca4cc88..52a7bf2f4 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,6 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) + var injectedContent: String? = nil + // custom emojis var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) @@ -71,10 +73,12 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + injectedContent: String? = nil ) { self.context = context self.composeKind = composeKind + self.injectedContent = injectedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -195,9 +199,16 @@ final class ComposeViewModel { // bind modal dismiss state composeStatusAttribute.composeContent .receive(on: DispatchQueue.main) - .map { content in + .map { [weak self] content in let content = content ?? "" - return content.isEmpty + if content.isEmpty { + return true + } + // if injectedContent plus a space is equal to the content, simply dismiss the modal + if let injectedContent = self?.injectedContent { + return content == (injectedContent + " ") + } + return false } .assign(to: \.value, on: shouldDismiss) .store(in: &disposeBag) @@ -304,6 +315,11 @@ final class ComposeViewModel { self.isPollToolbarButtonEnabled.value = !shouldPollDisable }) .store(in: &disposeBag) + + if let injectedContent = injectedContent { + // add a space after the injected text + composeStatusAttribute.composeContent.send(injectedContent + " ") + } } deinit { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..e4092ce0f --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -0,0 +1,88 @@ +// +// HashtagTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension HashtagTimelineViewController: StatusProvider { + + func toot() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .homeTimelineIndex(let objectID, _): + let managedObjectContext = self.viewModel.context.managedObjectContext + managedObjectContext.perform { + let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex + promise(.success(timelineIndex?.toot)) + } + default: + promise(.success(nil)) + } + } + } + + func toot(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.context.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift new file mode 100644 index 000000000..ba6d30e32 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -0,0 +1,279 @@ +// +// HashtagTimelineViewController.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit +import CoreData + +class HashtagTimelineViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: HashtagTimelineViewModel! + + let composeBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem() + barButtonItem.tintColor = Asset.Colors.Label.highlight.color + barButtonItem.image = UIImage(systemName: "square.and.pencil")?.withRenderingMode(.alwaysTemplate) + return barButtonItem + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension HashtagTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + title = "#\(viewModel.hashTag)" + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + navigationItem.rightBarButtonItem = composeBarButtonItem + + composeBarButtonItem.target = self + composeBarButtonItem.action = #selector(HashtagTimelineViewController.composeBarButtonItemPressed(_:)) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(HashtagTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self, + timelineMiddleLoaderTableViewCellDelegate: self + ) + + // bind refresh control + viewModel.isFetchingLatestTimeline + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } + tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + refreshControl.beginRefreshing() + refreshControl.sendActions(for: .valueChanged) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { _ in + // do nothing + } completion: { _ in + // fix AutoLayout cell height not update after rotate issue + self.viewModel.cellFrameCache.removeAllObjects() + self.tableView.reloadData() + } + } +} + +extension HashtagTimelineViewController { + + @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(HashtagTimelineViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } +} + +// MARK: - UIScrollViewDelegate +extension HashtagTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) +// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + } +} + +extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = HashtagTimelineViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +} + +// MARK: - UITableViewDelegate +extension HashtagTimelineViewController: UITableViewDelegate { + + // TODO: + // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + // + // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + // return 200 + // } + // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + // + // return ceil(frame.height) + // } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + + +// MARK: - UITableViewDataSourcePrefetching +extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - TimelineMiddleLoaderTableViewCellDelegate +extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineIndexObjectID = timelineIndexobjectID else { + return + } + viewModel.loadMiddleSateMachineList + .receive(on: DispatchQueue.main) + .sink { [weak self] ids in + guard let _ = self else { return } + if let stateMachine = ids[upperTimelineIndexObjectID] { + guard let state = stateMachine.currentState else { + assertionFailure() + return + } + + // make success state same as loading due to snapshot updating delay + let isLoading = state is HashtagTimelineViewModel.LoadMiddleState.Loading || state is HashtagTimelineViewModel.LoadMiddleState.Success + if isLoading { + cell.startAnimating() + } else { + cell.stopAnimating() + } + } else { + cell.stopAnimating() + } + } + .store(in: &cell.disposeBag) + + var dict = viewModel.loadMiddleSateMachineList.value + if let _ = dict[upperTimelineIndexObjectID] { + // do nothing + } else { + let stateMachine = GKStateMachine(states: [ + HashtagTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + HashtagTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperStatusObjectID: upperTimelineIndexObjectID), + ]) + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Initial.self) + dict[upperTimelineIndexObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict + } + } + + func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + switch item { + case .homeMiddleLoader(let upper): + guard let stateMachine = viewModel.loadMiddleSateMachineList.value[upper] else { + assertionFailure() + return + } + stateMachine.enter(HashtagTimelineViewModel.LoadMiddleState.Loading.self) + default: + assertionFailure() + } + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - StatusTableViewCellDelegate +extension HashtagTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..a41568787 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -0,0 +1,128 @@ +// +// HashtagTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + ) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + guard let tableView = self.tableView else { return } + guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + + let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + let oldSnapshot = diffableDataSource.snapshot() + let snapshot = snapshot as NSDiffableDataSourceSnapshot + + let statusItemList: [Item] = snapshot.itemIdentifiers.map { + let status = managedObjectContext.object(with: $0) as! Toot + + let isStatusTextSensitive: Bool = { + guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + } + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + + // Check if there is a `needLoadMiddleIndex` + if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { + // If yes, insert a `middleLoader` at the index + var newItems = statusItemList + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newSnapshot.appendItems(newItems, toSection: .main) + } else { + newSnapshot.appendItems(statusItemList, toSection: .main) + } + + if !(self.loadoldestStateMachine.currentState is LoadOldestState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + self.isFetchingLatestTimeline.value = false + return + } + + DispatchQueue.main.async { + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestTimeline.value = false + } + } + } + + private struct Difference { + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + + let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! + + guard let oldItemBeginIndexInNewSnapshot = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: oldItemAtBeginning) else { return nil } + + if oldItemBeginIndexInNewSnapshot > 0 { + let targetIndexPath = IndexPath(row: oldItemBeginIndexInNewSnapshot, section: 0) + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: IndexPath(row: 0, section: 0), navigationBar: navigationBar) + return Difference( + targetIndexPath: targetIndexPath, + offset: offset + ) + } + return nil + } + +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift new file mode 100644 index 000000000..b3cb2cc3b --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -0,0 +1,104 @@ +// +// HashtagTimelineViewModel+LoadLatestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +extension HashtagTimelineViewModel { + class LoadLatestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadLatestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadLatestState { + class Initial: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + // sign out when loading will enter here + stateMachine.enter(Fail.self) + return + } + // TODO: only set large count when using Wi-Fi + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + viewModel.isFetchingLatestTimeline.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + + } receiveValue: { response in + let newStatusIDList = response.value.map { $0.id } + + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) + } else { + viewModel.needLoadMiddleIndex = nil + } + + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) + viewModel.hashtagStatusIDList.removeDuplicates() + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } +} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift new file mode 100644 index 000000000..3c3b01d87 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -0,0 +1,131 @@ +// +// HashtagTimelineViewModel+LoadMiddleState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreData +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadMiddleState: GKState { + weak var viewModel: HashtagTimelineViewModel? + let upperStatusObjectID: NSManagedObjectID + + init(viewModel: HashtagTimelineViewModel, upperStatusObjectID: NSManagedObjectID) { + self.viewModel = viewModel + self.upperStatusObjectID = upperStatusObjectID + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + var dict = viewModel.loadMiddleSateMachineList.value + dict[upperStatusObjectID] = stateMachine + viewModel.loadMiddleSateMachineList.value = dict // trigger value change + } + } +} + +extension HashtagTimelineViewModel.LoadMiddleState { + + class Initial: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Success.self || stateClass == Fail.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + stateMachine.enter(Fail.self) + return + } + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + status.id + } + + // TODO: only set large count when using Wi-Fi + let maxID = upperStatusObject.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + // TODO: handle error + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(Success.self) + + let newStatusIDList = response.value.map { $0.id } + + if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + // When response data: + // 1. is not empty + // 2. last status are not recorded + // Then we may have middle data to load + if let lastNewStatusID = newStatusIDList.last, + !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count + } else { + viewModel.needLoadMiddleIndex = nil + } + viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + viewModel.hashtagStatusIDList.removeDuplicates() + } else { + // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index + // Then there is no need to set a `loadMiddleState` cell + viewModel.needLoadMiddleIndex = nil + } + + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return stateClass == Loading.self + } + } + + class Success: HashtagTimelineViewModel.LoadMiddleState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // guard let viewModel = viewModel else { return false } + return false + } + } + +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift new file mode 100644 index 000000000..f503420a7 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -0,0 +1,121 @@ +// +// HashtagTimelineViewModel+LoadOldestState.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import os.log +import Foundation +import GameplayKit +import CoreDataStack + +extension HashtagTimelineViewModel { + class LoadOldestState: GKState { + weak var viewModel: HashtagTimelineViewModel? + + init(viewModel: HashtagTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension HashtagTimelineViewModel.LoadOldestState { + class Initial: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + stateMachine.enter(Idle.self) + return + } + + // TODO: only set large count when using Wi-Fi + let maxID = last.id + viewModel.context.apiService.hashtagTimeline( + domain: activeMastodonAuthenticationBox.domain, + maxID: maxID, + hashtag: viewModel.hashTag, + authorizationBox: activeMastodonAuthenticationBox) + .delay(for: .seconds(1), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { completion in +// viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { response in + let toots = response.value + // enter no more state when no new toots + if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + let newStatusIDList = toots.map { $0.id } + viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) + let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + viewModel.timelinePredicate.send(newPredicate) + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: HashtagTimelineViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} + diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift new file mode 100644 index 000000000..19144d199 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -0,0 +1,112 @@ +// +// HashtagTimelineViewModel.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +final class HashtagTimelineViewModel: NSObject { + + let hashTag: String + + var disposeBag = Set() + + var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() + var needLoadMiddleIndex: Int? = nil + + // input + let context: AppContext + let fetchedResultsController: NSFetchedResultsController + let isFetchingLatestTimeline = CurrentValueSubject(false) + let timelinePredicate = CurrentValueSubject(nil) + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + // top loader + private(set) lazy var loadLatestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadLatestState.Initial(viewModel: self), + LoadLatestState.Loading(viewModel: self), + LoadLatestState.Fail(viewModel: self), + LoadLatestState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadLatestState.Initial.self) + return stateMachine + }() + lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + // middle loader + let loadMiddleSateMachineList = CurrentValueSubject<[NSManagedObjectID: GKStateMachine], Never>([:]) // TimelineIndex.objectID : middle loading state machine + var diffableDataSource: UITableViewDiffableDataSource? + var cellFrameCache = NSCache() + + + init(context: AppContext, hashTag: String) { + self.context = context + self.hashTag = hashTag + self.fetchedResultsController = { + let fetchRequest = Toot.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + timelinePredicate + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + self.diffableDataSource?.defaultRowAnimation = .fade + try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 6d7800b04..7db897f62 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -15,6 +15,7 @@ protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) } final class StatusView: UIView { @@ -400,6 +401,7 @@ extension StatusView { statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false playerContainerView.delegate = self + activeTextLabel.delegate = self contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) @@ -467,6 +469,13 @@ extension StatusView: AvatarConfigurableView { var configurableVerifiedBadgeImageView: UIImageView? { nil } } +// MARK: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusView(self, didSelectActiveEntity: activeLabel, entity: entity) + } +} + #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5f9bdf654..cb74bbf13 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -11,6 +11,7 @@ import AVKit import Combine import CoreData import CoreDataStack +import ActiveLabel protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } @@ -29,6 +30,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, didSelectActiveEntity entity: ActiveEntity) } extension StatusTableViewCellDelegate { @@ -206,6 +209,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } + func statusView(_ statusView: StatusView, didSelectActiveEntity activeLabel: ActiveLabel, entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift new file mode 100644 index 000000000..d3e9d6208 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+HashtagTimeline.swift +// Mastodon +// +// Created by BradGao on 2021/3/30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func hashtagTimeline( + domain: String, + sinceID: Mastodon.Entity.Status.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestTootMaxCount, + local: Bool? = nil, + hashtag: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Timeline.HashtagTimelineQuery( + maxID: maxID, + sinceID: sinceID, + minID: nil, // prefer sinceID + limit: limit, + local: local, + onlyMedia: false + ) + + return Mastodon.API.Timeline.hashtag( + session: session, + domain: domain, + query: query, + hashtag: hashtag, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: query, + response: response, + persistType: .lookUp, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index 6ab897123..a9b5c4f12 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -18,7 +18,7 @@ extension Mastodon.API.Timeline { } static func hashtagTimelineEndpointURL(domain: String, hashtag: String) -> URL { return Mastodon.API.endpointURL(domain: domain) - .appendingPathComponent("tag/\(hashtag)") + .appendingPathComponent("timelines/tag/\(hashtag)") } /// View public timeline statuses @@ -98,17 +98,19 @@ extension Mastodon.API.Timeline { /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `HashtagTimelineQuery` with query parameters /// - hashtag: Content of a #hashtag, not including # symbol. + /// - authorization: User token, auth is required if public preview is disabled /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func hashtag( session: URLSession, domain: String, query: HashtagTimelineQuery, - hashtag: String + hashtag: String, + authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: hashtagTimelineEndpointURL(domain: domain, hashtag: hashtag), query: query, - authorization: nil + authorization: authorization ) return session.dataTaskPublisher(for: request) .tryMap { data, response in From 43ee11b863cd61bcbc06e6e0dd255f33c4f96da7 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Apr 2021 14:39:15 +0800 Subject: [PATCH 161/400] feat: [WIP] add profile scene --- .../CoreData.xcdatamodel/contents | 100 ++- CoreDataStack/CoreDataStack.swift | 2 +- CoreDataStack/Entity/Application.swift | 4 +- CoreDataStack/Entity/Attachment.swift | 2 +- CoreDataStack/Entity/Emoji.swift | 2 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 8 +- CoreDataStack/Entity/MastodonUser.swift | 168 ++++- CoreDataStack/Entity/Mention.swift | 2 +- CoreDataStack/Entity/Poll.swift | 2 +- CoreDataStack/Entity/PrivateNote.swift | 56 ++ .../Entity/{Toot.swift => Status.swift} | 86 +-- CoreDataStack/Entity/Tag.swift | 2 +- Localization/app.json | 22 + Localization/ios-infoPlist.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 209 +++++- .../xcshareddata/swiftpm/Package.resolved | 18 + Mastodon/Coordinator/SceneCoordinator.swift | 15 +- .../StatusFetchedResultsController.swift | 86 +++ Mastodon/Diffiable/Item/Item.swift | 44 +- .../Section/ComposeStatusSection.swift | 8 +- .../Diffiable/Section/StatusSection.swift | 85 +-- Mastodon/Extension/ActiveLabel.swift | 45 +- Mastodon/Extension/CGImage.swift | 154 ++++ .../CoreDataStack/MastodonUser.swift | 25 + .../{Toot.swift => Status.swift} | 4 +- Mastodon/Extension/UIImage.swift | 7 + .../Extension/UINavigationController.swift | 17 + Mastodon/Extension/UITabBarController.swift | 14 + Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 36 + Mastodon/Helper/MastodonField.swift | 48 ++ ...tent.swift => MastodonStatusContent.swift} | 51 +- Mastodon/Info.plist | 2 - ...Provider+StatusTableViewCellDelegate.swift | 29 +- ...der+UITableViewDataSourcePrefetching.swift | 14 +- .../StatusProvider+UITableViewDelegate.swift | 28 +- .../StatusProvider/StatusProvider.swift | 6 +- .../StatusProvider/StatusProviderFacade.swift | 109 ++- .../alert.yellow.colorset/Contents.json | 20 + Mastodon/Resources/en.lproj/InfoPlist.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 14 + ...edToStatusContentCollectionViewCell.swift} | 6 +- .../Scene/Compose/ComposeViewController.swift | 2 +- ...meTimelineViewController+DebugAction.swift | 30 +- ...imelineViewController+StatusProvider.swift | 8 +- .../HomeTimelineViewController.swift | 9 +- .../HomeTimelineViewModel+Diffable.swift | 11 +- ...omeTimelineViewModel+LoadLatestState.swift | 24 +- ...omeTimelineViewModel+LoadMiddleState.swift | 16 +- ...omeTimelineViewModel+LoadOldestState.swift | 10 +- .../HomeTimeline/HomeTimelineViewModel.swift | 2 +- .../Scene/MainTab/MainTabBarController.swift | 16 +- .../MastodonConfirmEmailViewController.swift | 4 + .../MastodonPickServerViewController.swift | 4 + .../MastodonRegisterViewController.swift | 4 + .../MastodonResendEmailViewController.swift | 6 + .../MastodonServerRulesViewController.swift | 4 + .../Welcome/WelcomeViewController.swift | 4 + .../Profile/CachedProfileViewModel.swift | 17 + .../Header/ProfileHeaderViewController.swift | 142 ++++ .../Header/View/ProfileFieldView.swift | 100 +++ .../View/ProfileFriendshipActionButton.swift | 71 ++ .../Header/View/ProfileHeaderView.swift | 247 +++++++ .../ProfileStatusDashboardMeterView.swift | 79 +++ .../View/ProfileStatusDashboardView.swift | 103 +++ .../Scene/Profile/MeProfileViewModel.swift | 33 + .../Scene/Profile/ProfileViewController.swift | 658 +++++++++++++++++- Mastodon/Scene/Profile/ProfileViewModel.swift | 197 ++++++ .../Paging/ProfilePagingViewController.swift | 47 ++ .../Paging/ProfilePagingViewModel.swift | 68 ++ .../ProfileSegmentedViewController.swift | 38 + ...imelineViewController+StatusProvider.swift | 87 +++ .../Timeline/UserTimelineViewController.swift | 145 ++++ .../UserTimelineViewModel+Diffable.swift | 37 + .../UserTimelineViewModel+State.swift | 262 +++++++ .../Timeline/UserTimelineViewModel.swift | 127 ++++ ...imelineViewController+StatusProvider.swift | 12 +- .../PublicTimelineViewController.swift | 18 +- .../PublicTimelineViewModel+Diffable.swift | 30 +- ...licTimelineViewModel+LoadMiddleState.swift | 44 +- .../PublicTimelineViewModel+State.swift | 30 +- .../PublicTimelineViewModel.swift | 14 +- ...veStatusBarStyleNavigationController.swift | 16 + ...ntStatusBarStyleNavigationController.swift | 14 - .../View/Button/RoundedEdgesButton.swift | 2 +- .../Scene/Share/View/Content/StatusView.swift | 28 +- .../TableviewCell/StatusTableViewCell.swift | 10 + .../TimelineMiddleLoaderTableViewCell.swift | 2 +- .../APIService/APIService+Favorite.swift | 38 +- .../APIService/APIService+HomeTimeline.swift | 2 +- .../APIService+PublicTimeline.swift | 2 +- .../APIService/APIService+Reblog.swift | 36 +- .../APIService/APIService+Relationship.swift | 65 ++ .../APIService/APIService+UserTimeline.swift | 70 ++ Mastodon/Service/APIService/APIService.swift | 2 +- .../APIService+CoreData+MastodonUser.swift | 52 +- .../CoreData/APIService+CoreData+Status.swift | 104 +-- .../APIService+Persist+PersistCache.swift | 30 +- .../APIService+Persist+PersistMemo.swift | 26 +- .../Persist/APIService+Persist+Status.swift | 74 +- .../Service/StatusPrefetchingService.swift | 6 +- Mastodon/Service/ViedeoPlaybackService.swift | 4 +- Mastodon/ViewController.swift | 15 + .../API/Mastodon+API+Account+Friendship.swift | 69 ++ .../API/Mastodon+API+Account.swift | 79 +++ 105 files changed, 4450 insertions(+), 613 deletions(-) create mode 100644 CoreDataStack/Entity/PrivateNote.swift rename CoreDataStack/Entity/{Toot.swift => Status.swift} (75%) create mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift create mode 100644 Mastodon/Extension/CGImage.swift rename Mastodon/Extension/CoreDataStack/{Toot.swift => Status.swift} (95%) create mode 100644 Mastodon/Extension/UINavigationController.swift create mode 100644 Mastodon/Extension/UITabBarController.swift create mode 100644 Mastodon/Helper/MastodonField.swift rename Mastodon/Helper/{TootContent.swift => MastodonStatusContent.swift} (83%) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json rename Mastodon/Scene/Compose/CollectionViewCell/{ComposeRepliedToTootContentCollectionViewCell.swift => ComposeRepliedToStatusContentCollectionViewCell.swift} (62%) create mode 100644 Mastodon/Scene/Profile/CachedProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift create mode 100644 Mastodon/Scene/Profile/MeProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/ProfileViewModel.swift create mode 100644 Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift create mode 100644 Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift create mode 100644 Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift create mode 100644 Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift create mode 100644 Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift delete mode 100644 Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift create mode 100644 Mastodon/Service/APIService/APIService+Relationship.swift create mode 100644 Mastodon/Service/APIService/APIService+UserTimeline.swift create mode 100644 Mastodon/ViewController.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index c9ebac45f..db4dc4c23 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,11 +1,11 @@ - + - + @@ -22,7 +22,7 @@ - + @@ -32,7 +32,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -72,17 +72,38 @@ + + + + + + + - - + + + + + + + + + + + + - - - - + + + + + + + + @@ -93,7 +114,7 @@ - + @@ -105,7 +126,7 @@ - + @@ -117,15 +138,13 @@ - - - - - - - + + + + + - + @@ -145,23 +164,31 @@ - - + + - + - - - + + + - - - - + + + + - - - + + + + + + + + + + + @@ -170,11 +197,12 @@ - + - + + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 02aa397ff..1d13ee5ee 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -38,7 +38,7 @@ public final class CoreDataStack { }() static func persistentContainer() -> NSPersistentContainer { - let bundles = [Bundle(for: Toot.self)] + let bundles = [Bundle(for: Status.self)] guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: bundles) else { fatalError("cannot locate bundles") } diff --git a/CoreDataStack/Entity/Application.swift b/CoreDataStack/Entity/Application.swift index c9aa22833..b40b2e5b2 100644 --- a/CoreDataStack/Entity/Application.swift +++ b/CoreDataStack/Entity/Application.swift @@ -17,8 +17,8 @@ public final class Application: NSManagedObject { @NSManaged public private(set) var website: String? @NSManaged public private(set) var vapidKey: String? - // one-to-many relationship - @NSManaged public private(set) var toots: Set + // one-to-one relationship + @NSManaged public private(set) var status: Status } public extension Application { diff --git a/CoreDataStack/Entity/Attachment.swift b/CoreDataStack/Entity/Attachment.swift index 33a0c0826..16f007bf1 100644 --- a/CoreDataStack/Entity/Attachment.swift +++ b/CoreDataStack/Entity/Attachment.swift @@ -28,7 +28,7 @@ public final class Attachment: NSManagedObject { @NSManaged public private(set) var index: NSNumber // many-to-one relastionship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } diff --git a/CoreDataStack/Entity/Emoji.swift b/CoreDataStack/Entity/Emoji.swift index 933baab96..e9ee9d235 100644 --- a/CoreDataStack/Entity/Emoji.swift +++ b/CoreDataStack/Entity/Emoji.swift @@ -20,7 +20,7 @@ public final class Emoji: NSManagedObject { @NSManaged public private(set) var category: String? // many-to-one relationship - @NSManaged public private(set) var toot: Toot? + @NSManaged public private(set) var status: Status? } public extension Emoji { diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index 192e06e52..a902f5ce5 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -22,7 +22,7 @@ final public class HomeTimelineIndex: NSManagedObject { // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } @@ -32,16 +32,16 @@ extension HomeTimelineIndex { public static func insert( into context: NSManagedObjectContext, property: Property, - toot: Toot + status: Status ) -> HomeTimelineIndex { let index: HomeTimelineIndex = context.insertObject() index.identifier = property.identifier index.domain = property.domain index.userID = property.userID - index.createdAt = toot.createdAt + index.createdAt = status.createdAt - index.toot = toot + index.status = status return index } diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index dc88d48a2..2228787b5 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -21,24 +21,44 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var displayName: String @NSManaged public private(set) var avatar: String @NSManaged public private(set) var avatarStatic: String? + @NSManaged public private(set) var header: String + @NSManaged public private(set) var headerStatic: String? + @NSManaged public private(set) var note: String? + @NSManaged public private(set) var url: String? + @NSManaged public private(set) var statusesCount: NSNumber + @NSManaged public private(set) var followingCount: NSNumber + @NSManaged public private(set) var followersCount: NSNumber @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date // one-to-one relationship - @NSManaged public private(set) var pinnedToot: Toot? + @NSManaged public private(set) var pinnedStatus: Status? @NSManaged public private(set) var mastodonAuthentication: MastodonAuthentication? // one-to-many relationship - @NSManaged public private(set) var toots: Set? + @NSManaged public private(set) var statuses: Set? // many-to-many relationship - @NSManaged public private(set) var favourite: Set? - @NSManaged public private(set) var reblogged: Set? - @NSManaged public private(set) var muted: Set? - @NSManaged public private(set) var bookmarked: Set? + @NSManaged public private(set) var favourite: Set? + @NSManaged public private(set) var reblogged: Set? + @NSManaged public private(set) var muted: Set? + @NSManaged public private(set) var bookmarked: Set? @NSManaged public private(set) var votePollOptions: Set? @NSManaged public private(set) var votePolls: Set? + // relationships + @NSManaged public private(set) var following: Set? + @NSManaged public private(set) var followingBy: Set? + @NSManaged public private(set) var followRequested: Set? + @NSManaged public private(set) var followRequestedBy: Set? + @NSManaged public private(set) var muting: Set? + @NSManaged public private(set) var mutingBy: Set? + @NSManaged public private(set) var blocking: Set? + @NSManaged public private(set) var blockingBy: Set? + @NSManaged public private(set) var endorsed: Set? + @NSManaged public private(set) var endorsedBy: Set? + @NSManaged public private(set) var domainBlocking: Set? + @NSManaged public private(set) var domainBlockingBy: Set? } @@ -60,6 +80,16 @@ extension MastodonUser { user.displayName = property.displayName user.avatar = property.avatar user.avatarStatic = property.avatarStatic + user.header = property.header + user.headerStatic = property.headerStatic + user.note = property.note + user.url = property.url + user.statusesCount = NSNumber(value: property.statusesCount) + user.followingCount = NSNumber(value: property.followingCount) + user.followersCount = NSNumber(value: property.followersCount) + + // Mastodon do not provide relationship on the `Account` + // Update relationship via attribute updating interface user.createdAt = property.createdAt user.updatedAt = property.networkDate @@ -93,6 +123,107 @@ extension MastodonUser { self.avatarStatic = avatarStatic } } + public func update(header: String) { + if self.header != header { + self.header = header + } + } + public func update(headerStatic: String?) { + if self.headerStatic != headerStatic { + self.headerStatic = headerStatic + } + } + public func update(note: String?) { + if self.note != note { + self.note = note + } + } + public func update(url: String?) { + if self.url != url { + self.url = url + } + } + public func update(statusesCount: Int) { + if self.statusesCount.intValue != statusesCount { + self.statusesCount = NSNumber(value: statusesCount) + } + } + public func update(followingCount: Int) { + if self.followingCount.intValue != followingCount { + self.followingCount = NSNumber(value: followingCount) + } + } + public func update(followersCount: Int) { + if self.followersCount.intValue != followersCount { + self.followersCount = NSNumber(value: followersCount) + } + } + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { + if isFollowing { + if !(self.followingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).add(mastodonUser) + } + } else { + if (self.followingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followingBy)).remove(mastodonUser) + } + } + } + public func update(isFollowRequested: Bool, by mastodonUser: MastodonUser) { + if isFollowRequested { + if !(self.followRequestedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).add(mastodonUser) + } + } else { + if (self.followRequestedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.followRequestedBy)).remove(mastodonUser) + } + } + } + public func update(isMuting: Bool, by mastodonUser: MastodonUser) { + if isMuting { + if !(self.mutingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).add(mastodonUser) + } + } else { + if (self.mutingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.mutingBy)).remove(mastodonUser) + } + } + } + public func update(isBlocking: Bool, by mastodonUser: MastodonUser) { + if isBlocking { + if !(self.blockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).add(mastodonUser) + } + } else { + if (self.blockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.blockingBy)).remove(mastodonUser) + } + } + } + public func update(isEndorsed: Bool, by mastodonUser: MastodonUser) { + if isEndorsed { + if !(self.endorsedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).add(mastodonUser) + } + } else { + if (self.endorsedBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.endorsedBy)).remove(mastodonUser) + } + } + } + public func update(isDomainBlocking: Bool, by mastodonUser: MastodonUser) { + if isDomainBlocking { + if !(self.domainBlockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).add(mastodonUser) + } + } else { + if (self.domainBlockingBy ?? Set()).contains(mastodonUser) { + self.mutableSetValue(forKey: #keyPath(MastodonUser.domainBlockingBy)).remove(mastodonUser) + } + } + } public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate @@ -100,8 +231,8 @@ extension MastodonUser { } -public extension MastodonUser { - struct Property { +extension MastodonUser { + public struct Property { public let identifier: String public let domain: String @@ -111,6 +242,13 @@ public extension MastodonUser { public let displayName: String public let avatar: String public let avatarStatic: String? + public let header: String + public let headerStatic: String? + public let note: String? + public let url: String? + public let statusesCount: Int + public let followingCount: Int + public let followersCount: Int public let createdAt: Date public let networkDate: Date @@ -123,6 +261,13 @@ public extension MastodonUser { displayName: String, avatar: String, avatarStatic: String?, + header: String, + headerStatic: String?, + note: String?, + url: String?, + statusesCount: Int, + followingCount: Int, + followersCount: Int, createdAt: Date, networkDate: Date ) { @@ -134,6 +279,13 @@ public extension MastodonUser { self.displayName = displayName self.avatar = avatar self.avatarStatic = avatarStatic + self.header = header + self.headerStatic = headerStatic + self.note = note + self.url = url + self.statusesCount = statusesCount + self.followingCount = followingCount + self.followersCount = followersCount self.createdAt = createdAt self.networkDate = networkDate } diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index e659cf891..9559ea5d5 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -19,7 +19,7 @@ public final class Mention: NSManagedObject { @NSManaged public private(set) var url: String // many-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status } public extension Mention { diff --git a/CoreDataStack/Entity/Poll.swift b/CoreDataStack/Entity/Poll.swift index 356f2fc2e..3ab48b444 100644 --- a/CoreDataStack/Entity/Poll.swift +++ b/CoreDataStack/Entity/Poll.swift @@ -22,7 +22,7 @@ public final class Poll: NSManagedObject { @NSManaged public private(set) var updatedAt: Date // one-to-one relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var status: Status // one-to-many relationship @NSManaged public private(set) var options: Set diff --git a/CoreDataStack/Entity/PrivateNote.swift b/CoreDataStack/Entity/PrivateNote.swift new file mode 100644 index 000000000..2e02db25c --- /dev/null +++ b/CoreDataStack/Entity/PrivateNote.swift @@ -0,0 +1,56 @@ +// +// PrivateNote.swift +// CoreDataStack +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import CoreData +import Foundation + +final public class PrivateNote: NSManagedObject { + + @NSManaged public private(set) var note: String? + + @NSManaged public private(set) var updatedAt: Date + + // many-to-one relationship + @NSManaged public private(set) var to: MastodonUser? + @NSManaged public private(set) var from: MastodonUser + +} + +extension PrivateNote { + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(PrivateNote.updatedAt)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> PrivateNote { + let privateNode: PrivateNote = context.insertObject() + privateNode.note = property.note + return privateNode + } +} + +extension PrivateNote { + public struct Property { + public let note: String? + + init(note: String) { + self.note = note + } + } + +} + +extension PrivateNote: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \PrivateNote.updatedAt, ascending: false)] + } +} + diff --git a/CoreDataStack/Entity/Toot.swift b/CoreDataStack/Entity/Status.swift similarity index 75% rename from CoreDataStack/Entity/Toot.swift rename to CoreDataStack/Entity/Status.swift index 43e728283..f40f78639 100644 --- a/CoreDataStack/Entity/Toot.swift +++ b/CoreDataStack/Entity/Status.swift @@ -8,7 +8,7 @@ import CoreData import Foundation -public final class Toot: NSManagedObject { +public final class Status: NSManagedObject { public typealias ID = String @NSManaged public private(set) var identifier: ID @@ -30,7 +30,7 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var repliesCount: NSNumber? @NSManaged public private(set) var url: String? - @NSManaged public private(set) var inReplyToID: Toot.ID? + @NSManaged public private(set) var inReplyToID: Status.ID? @NSManaged public private(set) var inReplyToAccountID: MastodonUser.ID? @NSManaged public private(set) var language: String? // (ISO 639 Part 1 two-letter language code) @@ -38,8 +38,8 @@ public final class Toot: NSManagedObject { // many-to-one relastionship @NSManaged public private(set) var author: MastodonUser - @NSManaged public private(set) var reblog: Toot? - @NSManaged public private(set) var replyTo: Toot? + @NSManaged public private(set) var reblog: Status? + @NSManaged public private(set) var replyTo: Status? // many-to-many relastionship @NSManaged public private(set) var favouritedBy: Set? @@ -52,27 +52,27 @@ public final class Toot: NSManagedObject { @NSManaged public private(set) var poll: Poll? // one-to-many relationship - @NSManaged public private(set) var reblogFrom: Set? + @NSManaged public private(set) var reblogFrom: Set? @NSManaged public private(set) var mentions: Set? @NSManaged public private(set) var emojis: Set? @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? - @NSManaged public private(set) var replyFrom: Set? + @NSManaged public private(set) var replyFrom: Set? @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? } -public extension Toot { +public extension Status { @discardableResult static func insert( into context: NSManagedObjectContext, property: Property, author: MastodonUser, - reblog: Toot?, + reblog: Status?, application: Application?, - replyTo: Toot?, + replyTo: Status?, poll: Poll?, mentions: [Mention]?, emojis: [Emoji]?, @@ -83,8 +83,8 @@ public extension Toot { mutedBy: MastodonUser?, bookmarkedBy: MastodonUser?, pinnedBy: MastodonUser? - ) -> Toot { - let toot: Toot = context.insertObject() + ) -> Status { + let toot: Status = context.insertObject() toot.identifier = property.identifier toot.domain = property.domain @@ -117,28 +117,28 @@ public extension Toot { toot.poll = poll if let mentions = mentions { - toot.mutableSetValue(forKey: #keyPath(Toot.mentions)).addObjects(from: mentions) + toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } if let emojis = emojis { - toot.mutableSetValue(forKey: #keyPath(Toot.emojis)).addObjects(from: emojis) + toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) } if let tags = tags { - toot.mutableSetValue(forKey: #keyPath(Toot.tags)).addObjects(from: tags) + toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } if let mediaAttachments = mediaAttachments { - toot.mutableSetValue(forKey: #keyPath(Toot.mediaAttachments)).addObjects(from: mediaAttachments) + toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } if let favouritedBy = favouritedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(favouritedBy) + toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) } if let rebloggedBy = rebloggedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(rebloggedBy) + toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) } if let mutedBy = mutedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mutedBy) + toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) } if let bookmarkedBy = bookmarkedBy { - toot.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(bookmarkedBy) + toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) } toot.updatedAt = property.networkDate @@ -167,56 +167,56 @@ public extension Toot { } } - func update(replyTo: Toot?) { + func update(replyTo: Status?) { if self.replyTo != replyTo { self.replyTo = replyTo } } - func update(liked: Bool, mastodonUser: MastodonUser) { + func update(liked: Bool, by mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) } } else { if (self.favouritedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.favouritedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).remove(mastodonUser) } } } - func update(reblogged: Bool, mastodonUser: MastodonUser) { + func update(reblogged: Bool, by mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) } } else { if (self.rebloggedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.rebloggedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).remove(mastodonUser) } } } - func update(muted: Bool, mastodonUser: MastodonUser) { + func update(muted: Bool, by mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) } } else { if (self.mutedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.mutedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).remove(mastodonUser) } } } - func update(bookmarked: Bool, mastodonUser: MastodonUser) { + func update(bookmarked: Bool, by mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).add(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) } } else { if (self.bookmarkedBy ?? Set()).contains(mastodonUser) { - self.mutableSetValue(forKey: #keyPath(Toot.bookmarkedBy)).remove(mastodonUser) + self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).remove(mastodonUser) } } } @@ -227,7 +227,7 @@ public extension Toot { } -public extension Toot { +public extension Status { struct Property { public let identifier: ID @@ -247,7 +247,7 @@ public extension Toot { public let repliesCount: NSNumber? public let url: String? - public let inReplyToID: Toot.ID? + public let inReplyToID: Status.ID? public let inReplyToAccountID: MastodonUser.ID? public let language: String? // (ISO 639 Part @1 two-letter language code) public let text: String? @@ -267,7 +267,7 @@ public extension Toot { favouritesCount: NSNumber, repliesCount: NSNumber?, url: String?, - inReplyToID: Toot.ID?, + inReplyToID: Status.ID?, inReplyToAccountID: MastodonUser.ID?, language: String?, text: String?, @@ -296,20 +296,20 @@ public extension Toot { } } -extension Toot: Managed { +extension Status: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Toot.createdAt, ascending: false)] + return [NSSortDescriptor(keyPath: \Status.createdAt, ascending: false)] } } -extension Toot { +extension Status { static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.domain), domain) + return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) } static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Toot.id), id) + return NSPredicate(format: "%K == %@", #keyPath(Status.id), id) } public static func predicate(domain: String, id: String) -> NSPredicate { @@ -320,7 +320,7 @@ extension Toot { } static func predicate(ids: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(Toot.id), ids) + return NSPredicate(format: "%K IN %@", #keyPath(Status.id), ids) } public static func predicate(domain: String, ids: [String]) -> NSPredicate { @@ -331,10 +331,10 @@ extension Toot { } public static func notDeleted() -> NSPredicate { - return NSPredicate(format: "%K == nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K == nil", #keyPath(Status.deletedAt)) } public static func deleted() -> NSPredicate { - return NSPredicate(format: "%K != nil", #keyPath(Toot.deletedAt)) + return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } } diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index d817c774b..2f1914c4a 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -17,7 +17,7 @@ public final class Tag: NSManagedObject { @NSManaged public private(set) var url: String // many-to-many relationship - @NSManaged public private(set) var toot: Toot + @NSManaged public private(set) var statuses: Set? // one-to-many relationship @NSManaged public private(set) var histories: Set? diff --git a/Localization/app.json b/Localization/app.json index 99868a8fb..4e154be9c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -32,6 +32,7 @@ "edit": "Edit", "save": "Save", "ok": "OK", + "done": "Done", "confirm": "Confirm", "continue": "Continue", "cancel": "Cancel", @@ -65,6 +66,15 @@ "closed": "Closed" } }, + "firendship": { + "follow": "Follow", + "following": "Following", + "block": "Block", + "blocked": "Blocked", + "mute": "Mute", + "muted": "Muted", + "edit_info": "Edit info" + }, "timeline": { "loader": { "load_missing_posts": "Load missing posts", @@ -231,6 +241,18 @@ "private": "Followers only", "direct": "Only people I mention" } + }, + "profile": { + "dashboard": { + "posts": "posts", + "following": "following", + "followers": "followers" + }, + "segmented_control": { + "posts": "Posts", + "replies": "Replies", + "media": "Media" + } } } } \ No newline at end of file diff --git a/Localization/ios-infoPlist.json b/Localization/ios-infoPlist.json index 0a260c273..f25dbcc0e 100644 --- a/Localization/ios-infoPlist.json +++ b/Localization/ios-infoPlist.json @@ -1,4 +1,4 @@ { - "NSCameraUsageDescription": "Used to take photo for toot", + "NSCameraUsageDescription": "Used to take photo for post status", "NSPhotoLibraryAddUsageDescription": "Used to save photo into the Photo Library" -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 22d59f747..03a831f36 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */; }; 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */; }; 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */ = {isa = PBXBuildFile; productRef = 2D42FF6025C8177C004A627A /* ActiveLabel */; }; - 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* TootContent.swift */; }; + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */; }; 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */; }; 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */; }; 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; @@ -109,7 +109,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Toot.swift */; }; + DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; @@ -125,6 +125,9 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; + DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; }; + DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = DB3D100F25BAA75E00EAA174 /* Localizable.strings */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; @@ -155,6 +158,9 @@ DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */; }; DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; + DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; + DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; + DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -173,7 +179,7 @@ DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */; }; + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; @@ -190,7 +196,7 @@ DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */; }; + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; @@ -205,7 +211,7 @@ DB89BA1B25C1107F008580ED /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1825C1107F008580ED /* Collection.swift */; }; DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */; }; DB89BA1D25C1107F008580ED /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA1A25C1107F008580ED /* URL.swift */; }; - DB89BA2725C110B4008580ED /* Toot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Toot.swift */; }; + DB89BA2725C110B4008580ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA2625C110B4008580ED /* Status.swift */; }; DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA3525C1145C008580ED /* CoreData.xcdatamodeld */; }; DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */; }; DB89BA4425C1165F008580ED /* Managed.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB89BA4225C1165F008580ED /* Managed.swift */; }; @@ -244,9 +250,30 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */; }; + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */; }; + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */; }; + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */; }; + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; + DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; }; + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; + DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */; }; + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; + DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; }; + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; }; + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9A2615849F0045B23D /* PrivateNote.swift */; }; DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; @@ -343,7 +370,7 @@ 2D38F1FD25CD481700561493 /* StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProvider.swift; sourceTree = ""; }; 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisposeBagCollectable.swift; sourceTree = ""; }; 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITapGestureRecognizer.swift; sourceTree = ""; }; - 2D42FF6A25C817D2004A627A /* TootContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TootContent.swift; sourceTree = ""; }; + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonStatusContent.swift; sourceTree = ""; }; 2D42FF7D25C82218004A627A /* ActionToolBarContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionToolBarContainer.swift; sourceTree = ""; }; 2D42FF8425C8224F004A627A /* HitTestExpandedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HitTestExpandedButton.swift; sourceTree = ""; }; 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; @@ -412,7 +439,7 @@ DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; - DB084B5625CBC56C00F898ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; + DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; @@ -429,6 +456,9 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = ""; }; + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; + DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; DB3D100E25BAA75E00EAA174 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -465,6 +495,9 @@ DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; + DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; + DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -482,7 +515,7 @@ DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DarkContentStatusBarStyleNavigationController.swift; sourceTree = ""; }; + DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; @@ -499,7 +532,7 @@ DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToTootContentCollectionViewCell.swift; sourceTree = ""; }; + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -516,7 +549,7 @@ DB89BA1825C1107F008580ED /* Collection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; DB89BA1925C1107F008580ED /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB89BA1A25C1107F008580ED /* URL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; - DB89BA2625C110B4008580ED /* Toot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toot.swift; sourceTree = ""; }; + DB89BA2625C110B4008580ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB89BA3625C1145C008580ED /* CoreData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreData.xcdatamodel; sourceTree = ""; }; DB89BA4125C1165F008580ED /* NetworkUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkUpdatable.swift; sourceTree = ""; }; DB89BA4225C1165F008580ED /* Managed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Managed.swift; sourceTree = ""; }; @@ -554,9 +587,29 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; + DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewController.swift; sourceTree = ""; }; + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewModel.swift; sourceTree = ""; }; + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+Diffable.swift"; sourceTree = ""; }; + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; + DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; + DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; + DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; }; + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; }; + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; }; + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateNote.swift; sourceTree = ""; }; DBCCC71D25F73297007E1AB6 /* APIService+Reblog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Reblog.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; @@ -576,6 +629,7 @@ 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */, 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, + DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, @@ -834,6 +888,7 @@ children = ( 2D76319D25C151F600929FB9 /* Section */, 2D7631B125C159E700929FB9 /* Item */, + DBCBED2226132E1D00B49291 /* FetchedResultsController */, ); path = Diffiable; sourceTree = ""; @@ -958,7 +1013,7 @@ isa = PBXGroup; children = ( DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, - DB084B5625CBC56C00F898ED /* Toot.swift */, + DB084B5625CBC56C00F898ED /* Status.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, ); path = CoreDataStack; @@ -1039,6 +1094,7 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, + DBCC3B7A261443AD0045B23D /* ViewController.swift */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -1089,12 +1145,14 @@ DB98339B25C96DE600AD9700 /* APIService+Account.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, + DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, + DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, ); path = APIService; sourceTree = ""; @@ -1152,7 +1210,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */ = { isa = PBXGroup; children = ( - DB68A04925E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift */, + DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */, ); path = NavigationController; sourceTree = ""; @@ -1191,7 +1249,7 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */ = { isa = PBXGroup; children = ( - DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift */, + DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, @@ -1249,7 +1307,7 @@ DB89BA2C25C110B7008580ED /* Entity */ = { isa = PBXGroup; children = ( - DB89BA2625C110B4008580ED /* Toot.swift */, + DB89BA2625C110B4008580ED /* Status.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, 2D927F0125C7E4F2004F19B8 /* Mention.swift */, @@ -1261,6 +1319,7 @@ DB9D6C2D25E504AC0051B173 /* Attachment.swift */, DB4481AC25EE155900BEFB67 /* Poll.swift */, DB4481B225EE16D000BEFB67 /* PollOption.swift */, + DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, ); path = Entity; sourceTree = ""; @@ -1335,6 +1394,7 @@ 0FAA101B25E10E760017CCDE /* UIFont.swift */, 2D939AB425EDD8A90076FA61 /* String.swift */, 2D206B7F25F5F45E00143C56 /* UIImage.swift */, + DBCC3B88261454BA0045B23D /* CGImage.swift */, 2D206B8525F5FB0900143C56 /* Double.swift */, 2D206B9125F60EA700143C56 /* UIControl.swift */, 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, @@ -1344,6 +1404,8 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, + DBCC3B2F261440A50045B23D /* UITabBarController.swift */, + DBCC3B35261440BA0045B23D /* UINavigationController.swift */, ); path = Extension; sourceTree = ""; @@ -1395,7 +1457,13 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( + DBB525132611EBB1002F1F29 /* Segmented */, + DBB525462611ED57002F1F29 /* Header */, + DBB5253B2611ECF5002F1F29 /* Timeline */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, + DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, + DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; sourceTree = ""; @@ -1425,7 +1493,8 @@ DB9E0D6925EDFFE500CFDD76 /* Helper */ = { isa = PBXGroup; children = ( - 2D42FF6A25C817D2004A627A /* TootContent.swift */, + 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, + DB35FC2E26130172006193C9 /* MastodonField.swift */, ); path = Helper; sourceTree = ""; @@ -1447,6 +1516,65 @@ path = View; sourceTree = ""; }; + DBB525132611EBB1002F1F29 /* Segmented */ = { + isa = PBXGroup; + children = ( + DBB525262611EBDA002F1F29 /* Paging */, + DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */, + ); + path = Segmented; + sourceTree = ""; + }; + DBB525262611EBDA002F1F29 /* Paging */ = { + isa = PBXGroup; + children = ( + DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */, + DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DBB5253B2611ECF5002F1F29 /* Timeline */ = { + isa = PBXGroup; + children = ( + DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */, + DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */, + DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */, + DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */, + DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */, + ); + path = Timeline; + sourceTree = ""; + }; + DBB525462611ED57002F1F29 /* Header */ = { + isa = PBXGroup; + children = ( + DBB525732612D5A5002F1F29 /* View */, + DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + ); + path = Header; + sourceTree = ""; + }; + DBB525732612D5A5002F1F29 /* View */ = { + isa = PBXGroup; + children = ( + DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, + DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, + DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */, + DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, + ); + path = View; + sourceTree = ""; + }; + DBCBED2226132E1D00B49291 /* FetchedResultsController */ = { + isa = PBXGroup; + children = ( + DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + ); + path = FetchedResultsController; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -1500,6 +1628,7 @@ 2D939AC725EE14620076FA61 /* CropViewController */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, DBE64A8A260C49D200E6359A /* TwitterTextEditor */, + DBB525072611EAC0002F1F29 /* Tabman */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -1631,6 +1760,7 @@ 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -1815,6 +1945,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, + DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, @@ -1822,14 +1954,17 @@ 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */, + DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */, 2D7631B325C159F700929FB9 /* Item.swift in Sources */, 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */, DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, + DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, @@ -1843,6 +1978,7 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, @@ -1855,19 +1991,23 @@ 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, + DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, + DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, + DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, + DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, @@ -1896,12 +2036,15 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, + DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, + DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, @@ -1918,9 +2061,11 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, + DBB525562611EDCA002F1F29 /* UserTimelineViewModel.swift in Sources */, 2D42FF7E25C82218004A627A /* ActionToolBarContainer.swift in Sources */, DB221B16260C395900AEFE46 /* CustomEmojiPickerInputViewModel.swift in Sources */, DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, @@ -1936,11 +2081,12 @@ 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, - DB68A04A25E9027700CFDF14 /* DarkContentStatusBarStyleNavigationController.swift in Sources */, + DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, @@ -1948,15 +2094,18 @@ DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, + DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, + DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, + DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, - DB084B5725CBC56C00F898ED /* Toot.swift in Sources */, + DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, @@ -1982,7 +2131,7 @@ 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, - 2D42FF6B25C817D2004A627A /* TootContent.swift in Sources */, + 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, @@ -1991,7 +2140,9 @@ 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, + DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, + DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, @@ -1999,18 +2150,22 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, + DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, - DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToTootContentCollectionViewCell.swift in Sources */, + DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2041,11 +2196,12 @@ DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, + DBCC3B9B261584A00045B23D /* PrivateNote.swift in Sources */, DB89BA3725C1145C008580ED /* CoreData.xcdatamodeld in Sources */, DB8AF52525C131D1002E6C99 /* MastodonUser.swift in Sources */, DB89BA1B25C1107F008580ED /* Collection.swift in Sources */, DB4481AD25EE155900BEFB67 /* Poll.swift in Sources */, - DB89BA2725C110B4008580ED /* Toot.swift in Sources */, + DB89BA2725C110B4008580ED /* Status.swift in Sources */, 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, @@ -2605,6 +2761,14 @@ minimumVersion = 1.4.1; }; }; + DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/uias/Tabman"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.11.0; + }; + }; DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; @@ -2660,6 +2824,11 @@ package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; productName = "UITextView+Placeholder"; }; + DBB525072611EAC0002F1F29 /* Tabman */ = { + isa = XCSwiftPackageProductDependency; + package = DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */; + productName = Tabman; + }; DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { isa = XCSwiftPackageProductDependency; package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4f2051528..3850a59d5 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,6 +55,15 @@ "version": "6.1.0" } }, + { + "package": "Pageboy", + "repositoryURL": "https://github.com/uias/Pageboy", + "state": { + "branch": null, + "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", + "version": "3.6.2" + } + }, { "package": "swift-nio", "repositoryURL": "https://github.com/apple/swift-nio.git", @@ -82,6 +91,15 @@ "version": "5.0.0" } }, + { + "package": "Tabman", + "repositoryURL": "https://github.com/uias/Tabman", + "state": { + "branch": null, + "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", + "version": "2.11.0" + } + }, { "package": "ThirdPartyMailer", "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git", diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..0f343c855 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -50,6 +50,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // profile + case profile(viewModel: ProfileViewModel) + // misc case alertController(alertController: UIAlertController) @@ -119,17 +122,18 @@ extension SceneCoordinator { presentingViewController.show(viewController, sender: sender) case .showDetail: - let navigationController = UINavigationController(rootViewController: viewController) + let navigationController = AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) presentingViewController.showDetailViewController(navigationController, sender: sender) case .modal(let animated, let completion): let modalNavigationController: UINavigationController = { if scene.isOnboarding { - return DarkContentStatusBarStyleNavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } else { return UINavigationController(rootViewController: viewController) } }() + modalNavigationController.modalPresentationCapturesStatusBarAppearance = true if let adaptivePresentationControllerDelegate = viewController as? UIAdaptivePresentationControllerDelegate { modalNavigationController.presentationController?.delegate = adaptivePresentationControllerDelegate } @@ -146,12 +150,15 @@ extension SceneCoordinator { sender?.navigationController?.pushViewController(viewController, animated: true) case .safariPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) case .activityViewControllerPresent(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) case .alertController(let animated, let completion): + viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) } @@ -197,6 +204,10 @@ private extension SceneCoordinator { let _viewController = ComposeViewController() _viewController.viewModel = viewModel viewController = _viewController + case .profile(let viewModel): + let _viewController = ProfileViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift new file mode 100644 index 000000000..704f2ab78 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -0,0 +1,86 @@ +// +// StatusFetchedResultsController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class StatusFetchedResultsController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + // output + let items = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: domain ?? "", ids: []) + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Status.predicate(domain: domain ?? "", ids: ids), + additionalTweetPredicate + ]) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = statusIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.items.value = items + } +} diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 63c73d3a4..cd07c8836 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -16,40 +16,44 @@ enum Item { case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) // normal list - case toot(objectID: NSManagedObjectID, attribute: StatusAttribute) + case status(objectID: NSManagedObjectID, attribute: StatusAttribute) // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) - case publicMiddleLoader(tootID: String) + case publicMiddleLoader(statusID: String) case bottomLoader } protocol StatusContentWarningAttribute { - var isStatusTextSensitive: Bool { get set } - var isStatusSensitive: Bool { get set } + var isStatusTextSensitive: Bool? { get set } + var isStatusSensitive: Bool? { get set } } extension Item { - class StatusAttribute: Equatable, Hashable, StatusContentWarningAttribute { - var isStatusTextSensitive: Bool - var isStatusSensitive: Bool + class StatusAttribute: StatusContentWarningAttribute { + var isStatusTextSensitive: Bool? + var isStatusSensitive: Bool? - public init( - isStatusTextSensitive: Bool, - isStatusSensitive: Bool + init( + isStatusTextSensitive: Bool? = nil, + isStatusSensitive: Bool? = nil ) { self.isStatusTextSensitive = isStatusTextSensitive self.isStatusSensitive = isStatusSensitive } - static func == (lhs: Item.StatusAttribute, rhs: Item.StatusAttribute) -> Bool { - return lhs.isStatusTextSensitive == rhs.isStatusTextSensitive && - lhs.isStatusSensitive == rhs.isStatusSensitive - } - - func hash(into hasher: inout Hasher) { - hasher.combine(isStatusTextSensitive) - hasher.combine(isStatusSensitive) + // delay attribute init + func setupForStatus(status: Status) { + if isStatusTextSensitive == nil { + isStatusTextSensitive = { + guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } + return true + }() + } + + if isStatusSensitive == nil { + isStatusSensitive = status.sensitive + } } } } @@ -59,7 +63,7 @@ extension Item: Equatable { switch (lhs, rhs) { case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): return objectIDLeft == objectIDRight - case (.toot(let objectIDLeft, _), .toot(let objectIDRight, _)): + case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight case (.bottomLoader, .bottomLoader): return true @@ -78,7 +82,7 @@ extension Item: Hashable { switch self { case .homeTimelineIndex(let objectID, _): hasher.combine(objectID) - case .toot(let objectID, _): + case .status(let objectID, _): hasher.combine(objectID) case .publicMiddleLoader(let upper): hasher.combine(String(describing: Item.publicMiddleLoader.self)) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 222c40246..e9785461a 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -49,14 +49,14 @@ extension ComposeStatusSection { ] collectionView, indexPath, item -> UICollectionViewCell? in switch item { case .replyTo(let repliedToStatusObjectID): - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToTootContentCollectionViewCell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell return cell - case .input(let replyToTootObjectID, let attribute): + case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { - guard let replyToTootObjectID = replyToTootObjectID, - let replyTo = managedObjectContext.object(with: replyToTootObjectID) as? Toot else { + guard let replyToStatusObjectID = replyToStatusObjectID, + let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { cell.statusView.headerContainerStackView.isHidden = true return } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 809e3b3cc..5e891e13a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -39,41 +39,41 @@ extension StatusSection { dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, - toot: timelineIndex.toot, + status: timelineIndex.status, requestUserID: timelineIndex.userID, statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate return cell - case .toot(let objectID, let attribute): + case .status(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" // configure cell managedObjectContext.performAndWait { - let toot = managedObjectContext.object(with: objectID) as! Toot + let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, - toot: toot, + status: status, requestUserID: requestUserID, statusItemAttribute: attribute ) } cell.delegate = statusTableViewCellDelegate return cell - case .publicMiddleLoader(let upperTimelineTootID): + case .publicMiddleLoader(let upperTimelineStatusID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: upperTimelineTootID, timelineIndexobjectID: nil) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: upperTimelineStatusID, timelineIndexobjectID: nil) return cell case .homeMiddleLoader(let upperTimelineIndexObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell cell.delegate = timelineMiddleLoaderTableViewCellDelegate - timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineTootID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) + timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell @@ -90,47 +90,50 @@ extension StatusSection { dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, - toot: Toot, + status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute ) { + // setup attribute + statusItemAttribute.setupForStatus(status: status.reblog ?? status) + // set header - StatusSection.configureHeader(cell: cell, toot: toot) - ManagedObjectObserver.observe(object: toot) + StatusSection.configureHeader(cell: cell, status: status) + ManagedObjectObserver.observe(object: status) .receive(on: DispatchQueue.main) .sink { _ in // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let newToot = object as? Toot else { return } - StatusSection.configureHeader(cell: cell, toot: newToot) + let newStatus = object as? Status else { return } + StatusSection.configureHeader(cell: cell, status: newStatus) } .store(in: &cell.disposeBag) // set name username cell.statusView.nameLabel.text = { - let author = (toot.reblog ?? toot).author + let author = (status.reblog ?? status).author return author.displayName.isEmpty ? author.username : author.displayName }() - cell.statusView.usernameLabel.text = "@" + (toot.reblog ?? toot).author.acct + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct // set avatar - if let reblog = toot.reblog { + if let reblog = status.reblog { cell.statusView.avatarButton.isHidden = true cell.statusView.avatarStackedContainerButton.isHidden = false cell.statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: reblog.author.avatarImageURL())) - cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + cell.statusView.avatarStackedContainerButton.bottomTrailingAvatarStackedImageView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) } else { cell.statusView.avatarButton.isHidden = false cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: toot.author.avatarImageURL())) + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) } // set text - cell.statusView.activeTextLabel.config(content: (toot.reblog ?? toot).content) + cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) // set status text content warning - let spoilerText = (toot.reblog ?? toot).spoilerText ?? "" - let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false + let spoilerText = (status.reblog ?? status).spoilerText ?? "" cell.statusView.isStatusTextSensitive = isStatusTextSensitive cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) cell.statusView.contentWarningTitle.text = { @@ -142,7 +145,7 @@ extension StatusSection { }() // prepare media attachments - let mediaAttachments = Array((toot.reblog ?? toot).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } + let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } // set image let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) @@ -184,7 +187,7 @@ extension StatusSection { } } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusItemAttribute.isStatusSensitive + let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive @@ -251,7 +254,7 @@ extension StatusSection { cell.statusView.playerContainerView.playerViewController.player = nil } // set poll - let poll = (toot.reblog ?? toot).poll + let poll = (status.reblog ?? status).poll StatusSection.configurePoll( cell: cell, poll: poll, @@ -278,10 +281,10 @@ extension StatusSection { } // toolbar - StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) // set date - let createdAt = (toot.reblog ?? toot).createdAt + let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow timestampUpdatePublisher .sink { _ in @@ -290,34 +293,34 @@ extension StatusSection { .store(in: &cell.disposeBag) // observe model change - ManagedObjectObserver.observe(object: toot.reblog ?? toot) + ManagedObjectObserver.observe(object: status.reblog ?? status) .receive(on: DispatchQueue.main) .sink { _ in // do nothing } receiveValue: { change in guard case .update(let object) = change.changeType, - let toot = object as? Toot else { return } - StatusSection.configureActionToolBar(cell: cell, toot: toot, requestUserID: requestUserID) + let status = object as? Status else { return } + StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) - os_log("%{public}s[%{public}ld], %{public}s: reblog count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.reblogsCount.intValue) - os_log("%{public}s[%{public}ld], %{public}s: like count label for toot %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, toot.id, toot.favouritesCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue) + os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue) } .store(in: &cell.disposeBag) } static func configureHeader( cell: StatusTableViewCell, - toot: Toot + status: Status ) { - if toot.reblog != nil { + if status.reblog != nil { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) cell.statusView.headerInfoLabel.text = { - let author = toot.author + let author = status.author let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() - } else if let replyTo = toot.replyTo { + } else if let replyTo = status.replyTo { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { @@ -332,29 +335,29 @@ extension StatusSection { static func configureActionToolBar( cell: StatusTableViewCell, - toot: Toot, + status: Status, requestUserID: String ) { - let toot = toot.reblog ?? toot + let status = status.reblog ?? status // set reply let replyCountTitle: String = { - let count = toot.repliesCount?.intValue ?? 0 + let count = status.repliesCount?.intValue ?? 0 return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) // set reblog - let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let reblogCountTitle: String = { - let count = toot.reblogsCount.intValue + let count = status.reblogsCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged // set like - let isLike = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false + let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { - let count = toot.favouritesCount.intValue + let count = status.favouritesCount.intValue return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 9219701f5..614735ad1 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -14,40 +14,61 @@ extension ActiveLabel { enum Style { case `default` - case timelineHeaderView + case profileField } convenience init(style: Style) { self.init() - switch style { - case .default: - font = .preferredFont(forTextStyle: .body) - textColor = Asset.Colors.Label.primary.color - case .timelineHeaderView: - font = .preferredFont(forTextStyle: .footnote) - textColor = .secondaryLabel - } - numberOfLines = 0 lineSpacing = 5 mentionColor = Asset.Colors.Label.highlight.color hashtagColor = Asset.Colors.Label.highlight.color URLColor = Asset.Colors.Label.highlight.color + #if DEBUG text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + #endif + + switch style { + case .default: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + case .profileField: + font = .preferredFont(forTextStyle: .body) + textColor = Asset.Colors.Label.primary.color + numberOfLines = 1 + } } } extension ActiveLabel { - func config(content: String) { + /// status content + func configure(content: String) { activeEntities.removeAll() - if let parseResult = try? TootContent.parse(toot: content) { + if let parseResult = try? MastodonStatusContent.parse(status: content) { text = parseResult.trimmed activeEntities = parseResult.activeEntities } else { text = "" } } + + /// account note + func configure(note: String) { + configure(content: note) + } } +extension ActiveLabel { + /// account field + func configure(field: String) { + activeEntities.removeAll() + if let parseResult = try? MastodonField.parse(field: field) { + text = parseResult.value + activeEntities = parseResult.activeEntities + } else { + text = "" + } + } +} diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift new file mode 100644 index 000000000..252f1289a --- /dev/null +++ b/Mastodon/Extension/CGImage.swift @@ -0,0 +1,154 @@ +// +// CGImage.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import CoreImage + +extension CGImage { + // Reference + // https://www.itu.int/dms_pubrec/itu-r/rec/bt/R-REC-BT.709-6-201506-I!!PDF-E.pdf + // Luma Y = 0.2126R + 0.7152G + 0.0722B + var brightness: CGFloat? { + let context = CIContext() // default with metal accelerate + let ciImage = CIImage(cgImage: self) + let rec709Image = context.createCGImage( + ciImage, + from: ciImage.extent, + format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.itur_709) // BT.709 a.k.a Rec.709 + ) + guard let image = rec709Image, + image.bitsPerPixel == 32, + let data = rec709Image?.dataProvider?.data, + let pointer = CFDataGetBytePtr(data) else { return nil } + + let length = CFDataGetLength(data) + guard length > 0 else { return nil} + + var luma: CGFloat = 0.0 + for i in stride(from: 0, to: length, by: 4) { + let r = pointer[i] + let g = pointer[i + 1] + let b = pointer[i + 2] + let Y = 0.2126 * CGFloat(r) + 0.7152 * CGFloat(g) + 0.0722 * CGFloat(b) + luma += Y + } + luma /= CGFloat(width * height) + return luma + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI +import UIKit + +class BrightnessView: UIView { + let label = UILabel() + let imageView = UIImageView() + + override init(frame: CGRect) { + super.init(frame: frame) + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + stackView.distribution = .fillEqually + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(label) + + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + label.textAlignment = .center + label.numberOfLines = 0 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setImage(_ image: UIImage) { + imageView.image = image + + guard let brightness = image.cgImage?.brightness, + let style = image.domainLumaCoefficientsStyle else { + label.text = "" + return + } + let styleDescription: String = { + switch style { + case .light: return "Light" + case .dark: return "Dark" + case .unspecified: fallthrough + @unknown default: + return "Unknown" + } + }() + + label.text = styleDescription + "\n" + "\(brightness)" + } +} + +struct CGImage_Brightness_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .black)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .gray)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .separator)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .red)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .green)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .blue)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + UIViewPreview(width: 375) { + let view = BrightnessView() + view.setImage(.placeholder(color: .secondarySystemGroupedBackground)) + return view + } + .previewLayout(.fixed(width: 375, height: 44)) + } + } + +} + +#endif + + diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 7575704ba..471adb815 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -19,6 +19,13 @@ extension MastodonUser.Property { displayName: entity.displayName, avatar: entity.avatar, avatarStatic: entity.avatarStatic, + header: entity.header, + headerStatic: entity.headerStatic, + note: entity.note, + url: entity.url, + statusesCount: entity.statusesCount, + followingCount: entity.followingCount, + followersCount: entity.followersCount, createdAt: entity.createdAt, networkDate: networkDate ) @@ -26,7 +33,25 @@ extension MastodonUser.Property { } extension MastodonUser { + + var displayNameWithFallback: String { + return !displayName.isEmpty ? displayName : username + } + + var acctWithDomain: String { + return username + "@" + domain + } + +} + +extension MastodonUser { + + public func headerImageURL() -> URL? { + return URL(string: header) + } + public func avatarImageURL() -> URL? { return URL(string: avatar) } + } diff --git a/Mastodon/Extension/CoreDataStack/Toot.swift b/Mastodon/Extension/CoreDataStack/Status.swift similarity index 95% rename from Mastodon/Extension/CoreDataStack/Toot.swift rename to Mastodon/Extension/CoreDataStack/Status.swift index 2fab537e6..cf4f8a1bd 100644 --- a/Mastodon/Extension/CoreDataStack/Toot.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -1,5 +1,5 @@ // -// Toot.swift +// Status.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/4. @@ -9,7 +9,7 @@ import Foundation import CoreDataStack import MastodonSDK -extension Toot.Property { +extension Status.Property { init(entity: Mastodon.Entity.Status, domain: String, networkDate: Date) { self.init( domain: domain, diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index 3c3c43400..072a3d4d4 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -39,6 +39,13 @@ extension UIImage { } } +extension UIImage { + var domainLumaCoefficientsStyle: UIUserInterfaceStyle? { + guard let brightness = cgImage?.brightness else { return nil } + return brightness > 100 ? .light : .dark // 0 ~ 255 + } +} + extension UIImage { func blur(radius: CGFloat) -> UIImage? { guard let inputImage = CIImage(image: self) else { return nil } diff --git a/Mastodon/Extension/UINavigationController.swift b/Mastodon/Extension/UINavigationController.swift new file mode 100644 index 000000000..54583e50a --- /dev/null +++ b/Mastodon/Extension/UINavigationController.swift @@ -0,0 +1,17 @@ +// +// UINavigationController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +// This not works! +// SeeAlso: `AdaptiveStatusBarStyleNavigationController` +extension UINavigationController { + open override var childForStatusBarStyle: UIViewController? { + assertionFailure("Won't enter here") + return visibleViewController + } +} diff --git a/Mastodon/Extension/UITabBarController.swift b/Mastodon/Extension/UITabBarController.swift new file mode 100644 index 000000000..9b1d91292 --- /dev/null +++ b/Mastodon/Extension/UITabBarController.swift @@ -0,0 +1,14 @@ +// +// UITabBarController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +extension UITabBarController { + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 82e7f8b1f..8276cfb20 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -38,6 +38,7 @@ internal enum Asset { internal static let disabled = ColorAsset(name: "Colors/Background/Poll/disabled") internal static let highlight = ColorAsset(name: "Colors/Background/Poll/highlight") } + internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 82fa696d8..5ce26bac8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -60,6 +60,8 @@ internal enum L10n { internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") /// Discard internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") + /// Done + internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") /// OK @@ -85,6 +87,22 @@ internal enum L10n { /// Try Again internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") } + internal enum Firendship { + /// Block + internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") + /// Blocked + internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Edit info + internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") + /// Follow + internal static let follow = L10n.tr("Localizable", "Common.Controls.Firendship.Follow") + /// Following + internal static let following = L10n.tr("Localizable", "Common.Controls.Firendship.Following") + /// Mute + internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") + /// Muted + internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + } internal enum Status { /// Tap to reveal that may be sensitive internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") @@ -263,6 +281,24 @@ internal enum L10n { internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") } } + internal enum Profile { + internal enum Dashboard { + /// followers + internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") + /// following + internal static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following") + /// posts + internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") + } + internal enum SegmentedControl { + /// Media + internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") + /// Posts + internal static let posts = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Posts") + /// Replies + internal static let replies = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Replies") + } + } internal enum PublicTimeline { /// Public internal static let title = L10n.tr("Localizable", "Scene.PublicTimeline.Title") diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift new file mode 100644 index 000000000..cbe87c09b --- /dev/null +++ b/Mastodon/Helper/MastodonField.swift @@ -0,0 +1,48 @@ +// +// MastodonField.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import ActiveLabel + +enum MastodonField { + + static func parse(field string: String) -> ParseResult { + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))") + let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") + let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") + + var entities: [ActiveEntity] = [] + + for match in mentionMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .mention(text, userInfo: nil)) + entities.append(entity) + } + + for match in hashtagMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .hashtag(text, userInfo: nil)) + entities.append(entity) + } + + for match in urlMatches { + guard let text = string.substring(with: match, at: 0) else { continue } + let entity = ActiveEntity(range: match.range, type: .url(text, trimmed: text, url: text, userInfo: nil)) + entities.append(entity) + } + + return ParseResult(value: string, activeEntities: entities) + } + +} + +extension MastodonField { + struct ParseResult { + let value: String + let activeEntities: [ActiveEntity] + } +} diff --git a/Mastodon/Helper/TootContent.swift b/Mastodon/Helper/MastodonStatusContent.swift similarity index 83% rename from Mastodon/Helper/TootContent.swift rename to Mastodon/Helper/MastodonStatusContent.swift index 55f71beac..5b535b806 100755 --- a/Mastodon/Helper/TootContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -1,5 +1,5 @@ // -// TootContent.swift +// MastodonStatusContent.swift // Mastodon // // Created by MainasuK Cirno on 2021/2/1. @@ -9,15 +9,15 @@ import Foundation import Kanna import ActiveLabel -enum TootContent { +enum MastodonStatusContent { - static func parse(toot: String) throws -> TootContent.ParseResult { - let toot = toot.replacingOccurrences(of: "
", with: "\n") - let rootNode = try Node.parse(document: toot) + static func parse(status: String) throws -> MastodonStatusContent.ParseResult { + let status = status.replacingOccurrences(of: "
", with: "\n") + let rootNode = try Node.parse(document: status) let text = String(rootNode.text) var activeEntities: [ActiveEntity] = [] - let entities = TootContent.Node.entities(in: rootNode) + let entities = MastodonStatusContent.Node.entities(in: rootNode) for entity in entities { let range = NSRange(entity.text.startIndex.. TootContent.Node { + static func parse(document: String) throws -> MastodonStatusContent.Node { let html = try HTML(html: document, encoding: .utf8) let body = html.body ?? nil let text = body?.text ?? "" let level = 0 - let children: [TootContent.Node] = body.flatMap { body in + let children: [MastodonStatusContent.Node] = body.flatMap { body in return Node.parse(element: body, parentText: text[...], parentLevel: level + 1) } ?? [] let node = Node( @@ -253,32 +252,32 @@ extension TootContent { } -extension TootContent.Node { +extension MastodonStatusContent.Node { enum `Type` { case url case mention case hashtag } - static func entities(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type != nil } + static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type != nil } } - static func hashtags(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .hashtag } + static func hashtags(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .hashtag } } - static func mentions(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .mention } + static func mentions(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .mention } } - static func urls(in node: TootContent.Node) -> [TootContent.Node] { - return TootContent.Node.collect(node: node) { node in node.type == .url } + static func urls(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { + return MastodonStatusContent.Node.collect(node: node) { node in node.type == .url } } } -extension TootContent.Node: CustomDebugStringConvertible { +extension MastodonStatusContent.Node: CustomDebugStringConvertible { var debugDescription: String { let linkInfo: String = { switch (href, hrefEllipsis) { diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index e3d8d4a91..266cc9424 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -73,7 +73,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - UIViewControllerBasedStatusBarAppearance - diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f3d31ff33..ffaa29b52 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -13,6 +13,19 @@ import CoreDataStack import MastodonSDK import ActiveLabel +// MARK: - StatusViewDelegate +extension StatusTableViewCellDelegate where Self: StatusProvider { + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .secondary, provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) { + StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) + } + +} + // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { @@ -31,7 +44,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(_, let attribute): attribute.isStatusTextSensitive = false - case .toot(_, let attribute): + case .status(_, let attribute): attribute.isStatusTextSensitive = false default: return @@ -66,7 +79,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(_, let attribute): attribute.isStatusSensitive = false - case .toot(_, let attribute): + case .status(_, let attribute): attribute.isStatusSensitive = false default: return @@ -89,16 +102,16 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - toot(for: cell, indexPath: nil) + status(for: cell, indexPath: nil) .receive(on: DispatchQueue.main) .setFailureType(to: Error.self) - .compactMap { toot -> AnyPublisher, Error>? in - guard let toot = (toot?.reblog ?? toot) else { return nil } - guard let poll = toot.poll else { return nil } + .compactMap { status -> AnyPublisher, Error>? in + guard let status = (status?.reblog ?? status) else { return nil } + guard let poll = status.poll else { return nil } let votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } let choices = votedOptions.map { $0.index.intValue } - let domain = poll.toot.domain + let domain = poll.status.domain button.isEnabled = false @@ -137,7 +150,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { let poll = option.poll let pollObjectID = option.poll.objectID - let domain = poll.toot.domain + let domain = poll.status.domain if poll.multiple { var votedOptions = poll.options.filter { ($0.votedBy ?? Set()).contains(where: { $0.id == activeMastodonAuthenticationBox.userID }) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift index 20f8e30a9..a0aaa543e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -11,7 +11,7 @@ import CoreDataStack extension StatusTableViewCellDelegate where Self: StatusProvider { func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - // prefetch reply toot + // prefetch reply status guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } let domain = activeMastodonAuthenticationBox.domain @@ -20,8 +20,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex - statusObjectIDs.append(homeTimelineIndex.toot.objectID) - case .toot(let objectID, _): + statusObjectIDs.append(homeTimelineIndex.status.objectID) + case .status(let objectID, _): statusObjectIDs.append(objectID) default: continue @@ -32,15 +32,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { backgroundManagedObjectContext.perform { [weak self] in guard let self = self else { return } for objectID in statusObjectIDs { - let toot = backgroundManagedObjectContext.object(with: objectID) as! Toot - guard let replyToID = toot.inReplyToID, toot.replyTo == nil else { + let status = backgroundManagedObjectContext.object(with: objectID) as! Status + guard let replyToID = status.inReplyToID, status.replyTo == nil else { // skip continue } self.context.statusPrefetchingService.prefetchReplyTo( domain: domain, - statusObjectID: toot.objectID, - statusID: toot.id, + statusObjectID: status.objectID, + statusID: status.id, replyToStatusID: replyToID, authorizationBox: activeMastodonAuthenticationBox ) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 89ae8e6eb..baaa708a1 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -17,15 +17,15 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // update poll when toot appear + // update poll when status appear let now = Date() var pollID: Mastodon.Entity.Poll.ID? - toot(for: cell, indexPath: indexPath) - .compactMap { [weak self] toot -> AnyPublisher, Error>? in + status(for: cell, indexPath: indexPath) + .compactMap { [weak self] status -> AnyPublisher, Error>? in guard let self = self else { return nil } guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } - guard let toot = (toot?.reblog ?? toot) else { return nil } - guard let poll = toot.poll else { return nil } + guard let status = (status?.reblog ?? status) else { return nil } + guard let poll = status.poll else { return nil } pollID = poll.id // not expired AND last update > 60s @@ -46,7 +46,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: poll %s info update…", (#file as NSString).lastPathComponent, #line, #function, poll.id) return self.context.apiService.poll( - domain: toot.domain, + domain: status.domain, pollID: poll.id, pollObjectID: poll.objectID, mastodonAuthenticationBox: authenticationBox @@ -68,11 +68,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { }) .store(in: &disposeBag) - toot(for: cell, indexPath: indexPath) - .sink { [weak self] toot in + status(for: cell, indexPath: indexPath) + .sink { [weak self] status in guard let self = self else { return } - let toot = toot?.reblog ?? toot - guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + let status = status?.reblog ?? status + guard let media = (status?.mediaAttachments ?? Set()).first else { return } guard let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) else { return } DispatchQueue.main.async { @@ -85,17 +85,17 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { // os_log("%{public}s[%{public}ld], %{public}s: indexPath %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) - toot(for: cell, indexPath: indexPath) - .sink { [weak self] toot in + status(for: cell, indexPath: indexPath) + .sink { [weak self] status in guard let self = self else { return } - guard let media = (toot?.mediaAttachments ?? Set()).first else { return } + guard let media = (status?.mediaAttachments ?? Set()).first else { return } if let videoPlayerViewModel = self.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: media) { DispatchQueue.main.async { videoPlayerViewModel.didEndDisplaying() } } - if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = toot?.mediaAttachments?.contains(currentAudioAttachment) { + if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) { self.context.audioPlaybackService.pause() } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index 4ec0d1e16..e16343ee6 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -12,9 +12,9 @@ import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async - func toot() -> Future - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future - func toot(for cell: UICollectionViewCell) -> Future + func status() -> Future + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func status(for cell: UICollectionViewCell) -> Future // sync var managedObjectContext: NSManagedObjectContext { get } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index cab16e68c..19b0fbf7e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -13,8 +13,53 @@ import CoreDataStack import MastodonSDK import ActiveLabel -enum StatusProviderFacade { +enum StatusProviderFacade { } +extension StatusProviderFacade { + + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider) { + _coordinateToStatusAuthorProfileScene( + for: target, + provider: provider, + status: provider.status() + ) + } + + static func coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { + _coordinateToStatusAuthorProfileScene( + for: target, + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _coordinateToStatusAuthorProfileScene(for target: Target, provider: StatusProvider, status: Future) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status // original status + case .secondary: return status?.replyTo ?? status // reblog or reply to status + } + }() + guard let status = _status else { return } + + let mastodonUser = status.author + let profileViewModel = CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + DispatchQueue.main.async { + if provider.navigationController == nil { + let from = provider.presentingViewController ?? provider + provider.dismiss(animated: true) { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: from, transition: .show) + } + } else { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } + } + .store(in: &provider.disposeBag) + } } extension StatusProviderFacade { @@ -22,18 +67,18 @@ extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot() + status: provider.status() ) } static func responseToStatusLikeAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusLikeAction( provider: provider, - toot: provider.toot(for: cell, indexPath: nil) + status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusLikeAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusLikeAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -55,22 +100,22 @@ extension StatusProviderFacade { let generator = UIImpactFeedbackGenerator(style: .light) let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in - guard let toot = toot?.reblog ?? toot else { return nil } + status + .compactMap { status -> (NSManagedObjectID, Mastodon.API.Favorites.FavoriteKind)? in + guard let status = status?.reblog ?? status else { return nil } let favoriteKind: Mastodon.API.Favorites.FavoriteKind = { - let isLiked = toot.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isLiked = status.favouritedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isLiked ? .destroy : .create }() - return (toot.objectID, favoriteKind) + return (status.objectID, favoriteKind) } - .map { tootObjectID, favoriteKind -> AnyPublisher<(Toot.ID, Mastodon.API.Favorites.FavoriteKind), Error> in + .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in return context.apiService.like( - tootObjectID: tootObjectID, + statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, favoriteKind: favoriteKind ) - .map { tootID in (tootID, favoriteKind) } + .map { statusID in (statusID, favoriteKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -82,7 +127,7 @@ extension StatusProviderFacade { responseFeedbackGenerator.prepare() } receiveOutput: { _, favoriteKind in generator.impactOccurred() - os_log("%{public}s[%{public}ld], %{public}s: [Like] update local toot like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") + os_log("%{public}s[%{public}ld], %{public}s: [Like] update local status like status to: %s", ((#file as NSString).lastPathComponent), #line, #function, favoriteKind == .create ? "like" : "unlike") } receiveCompletion: { completion in switch completion { case .failure: @@ -92,9 +137,9 @@ extension StatusProviderFacade { break } } - .map { tootID, favoriteKind in + .map { statusID, favoriteKind in return context.apiService.like( - statusID: tootID, + statusID: statusID, favoriteKind: favoriteKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) @@ -126,18 +171,18 @@ extension StatusProviderFacade { static func responseToStatusReblogAction(provider: StatusProvider) { _responseToStatusReblogAction( provider: provider, - toot: provider.toot() + status: provider.status() ) } static func responseToStatusReblogAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusReblogAction( provider: provider, - toot: provider.toot(for: cell, indexPath: nil) + status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusReblogAction(provider: StatusProvider, toot: Future) { + private static func _responseToStatusReblogAction(provider: StatusProvider, status: Future) { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() @@ -159,22 +204,22 @@ extension StatusProviderFacade { let generator = UIImpactFeedbackGenerator(style: .light) let responseFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - toot - .compactMap { toot -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in - guard let toot = toot?.reblog ?? toot else { return nil } + status + .compactMap { status -> (NSManagedObjectID, Mastodon.API.Reblog.ReblogKind)? in + guard let status = status?.reblog ?? status else { return nil } let reblogKind: Mastodon.API.Reblog.ReblogKind = { - let isReblogged = toot.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false + let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == mastodonUserID }) } ?? false return isReblogged ? .undoReblog : .reblog(query: .init(visibility: nil)) }() - return (toot.objectID, reblogKind) + return (status.objectID, reblogKind) } - .map { tootObjectID, reblogKind -> AnyPublisher<(Toot.ID, Mastodon.API.Reblog.ReblogKind), Error> in + .map { statusObjectID, reblogKind -> AnyPublisher<(Status.ID, Mastodon.API.Reblog.ReblogKind), Error> in return context.apiService.reblog( - tootObjectID: tootObjectID, + statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, reblogKind: reblogKind ) - .map { tootID in (tootID, reblogKind) } + .map { statusID in (statusID, reblogKind) } .eraseToAnyPublisher() } .setFailureType(to: Error.self) @@ -188,9 +233,9 @@ extension StatusProviderFacade { generator.impactOccurred() switch reblogKind { case .reblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "reblog") case .undoReblog: - os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local toot reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") + os_log("%{public}s[%{public}ld], %{public}s: [Reblog] update local status reblog status to: %s", ((#file as NSString).lastPathComponent), #line, #function, "unreblog") } } receiveCompletion: { completion in switch completion { @@ -201,9 +246,9 @@ extension StatusProviderFacade { break } } - .map { tootID, reblogKind in + .map { statusID, reblogKind in return context.apiService.reblog( - statusID: tootID, + statusID: statusID, reblogKind: reblogKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) @@ -231,8 +276,8 @@ extension StatusProviderFacade { extension StatusProviderFacade { enum Target { - case toot - case reblog + case primary // original + case secondary // attachment reblog or reply } } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json new file mode 100644 index 000000000..29b7bba3d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/alert.yellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.016", + "green" : "0.561", + "red" : "0.792" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/InfoPlist.strings b/Mastodon/Resources/en.lproj/InfoPlist.strings index 972e1a7a2..48566ae36 100644 --- a/Mastodon/Resources/en.lproj/InfoPlist.strings +++ b/Mastodon/Resources/en.lproj/InfoPlist.strings @@ -1,2 +1,2 @@ -"NSCameraUsageDescription" = "Used to take photo for toot"; +"NSCameraUsageDescription" = "Used to take photo for post status"; "NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c2bc09c68..6f70412a6 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -15,6 +15,7 @@ Please check your internet connection."; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; "Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; @@ -27,6 +28,13 @@ Please check your internet connection."; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; +"Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.Blocked" = "Blocked"; +"Common.Controls.Firendship.EditInfo" = "Edit info"; +"Common.Controls.Firendship.Follow" = "Follow"; +"Common.Controls.Firendship.Following" = "Following"; +"Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -85,6 +93,12 @@ tap the link to confirm your account."; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Email" = "Email"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift similarity index 62% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index fe00563df..0163a54cd 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToTootContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeRepliedToTootContentCollectionViewCell.swift +// ComposeRepliedToStatusContentCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. @@ -7,7 +7,7 @@ import UIKit -final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell { +final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { override init(frame: CGRect) { super.init(frame: frame) @@ -21,7 +21,7 @@ final class ComposeRepliedToTootContentCollectionViewCell: UICollectionViewCell } -extension ComposeRepliedToTootContentCollectionViewCell { +extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 53f71fc31..c4442bab3 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -43,7 +43,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { let collectionView: UICollectionView = { let collectionViewLayout = ComposeViewController.createLayout() let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout) - collectionView.register(ComposeRepliedToTootContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToTootContentCollectionViewCell.self)) + collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self)) collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self)) collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 6f289b339..785d97264 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -65,7 +65,7 @@ extension HomeTimelineViewController { guard let self = self else { return } self.moveToFirstVideoStatus(action) }), - UIAction(title: "First GIF Toot", image: nil, attributes: [], handler: { [weak self] action in + UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in guard let self = self else { return } self.moveToFirstGIFStatus(action) }), @@ -112,7 +112,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - return homeTimelineIndex.toot.reblog != nil + return homeTimelineIndex.status.reblog != nil default: return false } @@ -132,7 +132,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let post = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot + let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status return post.poll != nil default: return false @@ -153,7 +153,7 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - guard homeTimelineIndex.toot.inReplyToID != nil else { + guard homeTimelineIndex.status.inReplyToID != nil else { return false } return true @@ -176,8 +176,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false default: return false } @@ -186,7 +186,7 @@ extension HomeTimelineViewController { tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) tableView.blinkRow(at: IndexPath(row: index, section: 0)) } else { - print("Not found audio toot") + print("Not found audio status") } } @@ -197,8 +197,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false default: return false } @@ -218,8 +218,8 @@ extension HomeTimelineViewController { switch item { case .homeTimelineIndex(let objectID, _): let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let toot = homeTimelineIndex.toot.reblog ?? homeTimelineIndex.toot - return toot.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false default: return false } @@ -242,12 +242,12 @@ extension HomeTimelineViewController { default: return nil } } - var droppingTootObjectIDs: [NSManagedObjectID] = [] + var droppingStatusObjectIDs: [NSManagedObjectID] = [] context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } for objectID in droppingObjectIDs { guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - droppingTootObjectIDs.append(homeTimelineIndex.toot.objectID) + droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) } } @@ -257,8 +257,8 @@ extension HomeTimelineViewController { case .success: self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in guard let self = self else { return } - for objectID in droppingTootObjectIDs { - guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Toot else { continue } + for objectID in droppingStatusObjectIDs { + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } self.context.apiService.backgroundManagedObjectContext.delete(post) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index dc8eb4803..9e1915301 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -14,11 +14,11 @@ import CoreDataStack // MARK: - StatusProvider extension HomeTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -36,7 +36,7 @@ extension HomeTimelineViewController: StatusProvider { let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext managedObjectContext.perform { let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.toot)) + promise(.success(timelineIndex?.status)) } default: promise(.success(nil)) @@ -44,7 +44,7 @@ extension HomeTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 078b7b445..1f3dea81a 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -175,6 +175,13 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // needs trigger manually after onboarding dismiss + setNeedsStatusBarAppearanceUpdate() + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -313,7 +320,7 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 4a34b922a..5f16a18eb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -31,6 +31,10 @@ extension HomeTimelineViewModel { statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate ) + +// var snapshot = NSDiffableDataSourceSnapshot() +// snapshot.appendSections([.main]) +// diffableDataSource?.apply(snapshot) } } @@ -83,12 +87,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { var newTimelineItems: [Item] = [] for (i, timelineIndex) in timelineIndexes.enumerated() { - let toot = timelineIndex.toot.reblog ?? timelineIndex.toot - let isStatusTextSensitive: Bool = { - guard let spoilerText = toot.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: toot.sensitive) + let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 80c86a006..640d9df3b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -55,7 +55,7 @@ extension HomeTimelineViewModel.LoadLatestState { managedObjectContext.perform { let start = CACurrentMediaTime() - let latestTootIDs: [Toot.ID] + let latestStatusIDs: [Status.ID] let request = HomeTimelineIndex.sortedFetchRequest request.returnsObjectsAsFaults = false request.predicate = predicate @@ -64,10 +64,10 @@ extension HomeTimelineViewModel.LoadLatestState { let timelineIndexes = try managedObjectContext.fetch(request) let endFetch = CACurrentMediaTime() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect timelineIndexes cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, endFetch - start) - latestTootIDs = timelineIndexes - .prefix(APIService.onceRequestTootMaxCount) // avoid performance issue + latestStatusIDs = timelineIndexes + .prefix(APIService.onceRequestStatusMaxCount) // avoid performance issue .compactMap { timelineIndex in - timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.toot.id)) as? Toot.ID + timelineIndex.value(forKeyPath: #keyPath(HomeTimelineIndex.status.id)) as? Status.ID } } catch { stateMachine.enter(Fail.self) @@ -75,7 +75,7 @@ extension HomeTimelineViewModel.LoadLatestState { } let end = CACurrentMediaTime() - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect toots id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: collect statuses id cost: %.2fs", ((#file as NSString).lastPathComponent), #line, #function, end - start) // TODO: only set large count when using Wi-Fi viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) @@ -86,7 +86,7 @@ extension HomeTimelineViewModel.LoadLatestState { case .failure(let error): // TODO: handle error viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -95,15 +95,15 @@ extension HomeTimelineViewModel.LoadLatestState { stateMachine.enter(Idle.self) } receiveValue: { response in - // stop refresher if no new toots - let toots = response.value - let newToots = toots.filter { !latestTootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new toots", ((#file as NSString).lastPathComponent), #line, #function, newToots.count) + // stop refresher if no new statuses + let statuses = response.value + let newStatuses = statuses.filter { !latestStatusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, newStatuses.count) - if newToots.isEmpty { + if newStatuses.isEmpty { viewModel.isFetchingLatestTimeline.value = false } else { - if !latestTootIDs.isEmpty { + if !latestStatusIDs.isEmpty { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift index 07b6abf17..b5b9e4ceb 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadMiddleState.swift @@ -58,12 +58,12 @@ extension HomeTimelineViewModel.LoadMiddleState { stateMachine.enter(Fail.self) return } - let tootIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in - timelineIndex.toot.id + let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { timelineIndex in + timelineIndex.status.id } // TODO: only set large count when using Wi-Fi - let maxID = timelineIndex.toot.id + let maxID = timelineIndex.status.id viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain,maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) @@ -72,16 +72,16 @@ extension HomeTimelineViewModel.LoadMiddleState { switch completion { case .failure(let error): // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break } } receiveValue: { response in - let toots = response.value - let newToots = toots.filter { !tootIDs.contains($0.id) } - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", ((#file as NSString).lastPathComponent), #line, #function, toots.count, newToots.count) - if newToots.isEmpty { + let statuses = response.value + let newStatuses = statuses.filter { !statusIDs.contains($0.id) } + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statuses", ((#file as NSString).lastPathComponent), #line, #function, statuses.count, newStatuses.count) + if newStatuses.isEmpty { stateMachine.enter(Fail.self) } else { stateMachine.enter(Success.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 341183dcf..aaabd7a8b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -53,7 +53,7 @@ extension HomeTimelineViewModel.LoadOldestState { } // TODO: only set large count when using Wi-Fi - let maxID = last.toot.id + let maxID = last.status.id viewModel.context.apiService.homeTimeline(domain: activeMastodonAuthenticationBox.domain, maxID: maxID, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) @@ -61,15 +61,15 @@ extension HomeTimelineViewModel.LoadOldestState { viewModel.homeTimelineNavigationBarTitleViewModel.receiveLoadingStateCompletion(completion) switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 7b9c35308..1c0ddf71b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -74,7 +74,7 @@ final class HomeTimelineViewModel: NSObject { let fetchRequest = HomeTimelineIndex.sortedFetchRequest fetchRequest.fetchBatchSize = 20 fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.toot)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(HomeTimelineIndex.status)] let controller = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context.managedObjectContext, diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 72f62528a..9d609234f 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -63,10 +63,11 @@ class MainTabBarController: UITabBarController { let _viewController = ProfileViewController() _viewController.context = context _viewController.coordinator = coordinator + _viewController.viewModel = MeProfileViewModel(context: context) viewController = _viewController } viewController.title = self.title - return UINavigationController(rootViewController: viewController) + return AdaptiveStatusBarStyleNavigationController(rootViewController: viewController) } } @@ -84,6 +85,11 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { + + open override var childForStatusBarStyle: UIViewController? { + return selectedViewController + } + override func viewDidLoad() { super.viewDidLoad() @@ -100,9 +106,9 @@ extension MainTabBarController { selectedIndex = 0 // TODO: custom accent color - let tabBarAppearance = UITabBarAppearance() - tabBarAppearance.configureWithDefaultBackground() - tabBar.standardAppearance = tabBarAppearance +// let tabBarAppearance = UITabBarAppearance() +// tabBarAppearance.configureWithDefaultBackground() +// tabBar.standardAppearance = tabBarAppearance context.apiService.error .receive(on: DispatchQueue.main) @@ -151,7 +157,7 @@ extension MainTabBarController { .store(in: &disposeBag) #if DEBUG - // selectedIndex = 1 + // selectedIndex = 3 #endif } diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index dee1510cd..338be6ab6 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -66,6 +66,10 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc } extension MastodonConfirmEmailViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } override func viewDidLoad() { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 37e8b0dab..a358d5d60 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -56,6 +56,10 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 04aea3c19..4151cf3b5 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -221,6 +221,10 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O extension MastodonRegisterViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift index 25b9ca402..d97209317 100644 --- a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift @@ -40,6 +40,11 @@ final class MastodonResendEmailViewController: UIViewController, NeedsDependency } extension MastodonResendEmailViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() @@ -59,6 +64,7 @@ extension MastodonResendEmailViewController { webView.load(request) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: resendEmail via: %s", (#file as NSString).lastPathComponent, #line, #function, viewModel.resendEmailURL.debugDescription) } + } extension MastodonResendEmailViewController { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 467239b87..e0c6e3605 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -82,6 +82,10 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency extension MastodonServerRulesViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index e415c5737..838f1327a 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -64,6 +64,10 @@ final class WelcomeViewController: UIViewController, NeedsDependency { extension WelcomeViewController { + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift new file mode 100644 index 000000000..0e5823d09 --- /dev/null +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -0,0 +1,17 @@ +// +// CachedProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import Foundation +import CoreDataStack + +final class CachedProfileViewModel: ProfileViewModel { + + convenience init(context: AppContext, mastodonUser: MastodonUser) { + self.init(context: context, optionalMastodonUser: mastodonUser) + } + +} diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift new file mode 100644 index 000000000..58a7a6110 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -0,0 +1,142 @@ +// +// ProfileHeaderViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit + +protocol ProfileHeaderViewControllerDelegate: class { + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) +} + +final class ProfileHeaderViewController: UIViewController { + + static let segmentedControlHeight: CGFloat = 32 + static let segmentedControlMarginHeight: CGFloat = 20 + static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + + weak var delegate: ProfileHeaderViewControllerDelegate? + + let profileBannerView = ProfileHeaderView() + let pageSegmentedControl: UISegmentedControl = { + let segmenetedControl = UISegmentedControl(items: ["A", "B"]) + segmenetedControl.selectedSegmentIndex = 0 + return segmenetedControl + }() + + private var isBannerPinned = false + private var bottomShadowAlpha: CGFloat = 0.0 + + private var isAdjustBannerImageViewForSafeAreaInset = false + private var containerSafeAreaInset: UIEdgeInsets = .zero + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfileHeaderViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + profileBannerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileBannerView) + NSLayoutConstraint.activate([ + profileBannerView.topAnchor.constraint(equalTo: view.topAnchor), + profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + profileBannerView.preservesSuperviewLayoutMargins = true + + pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pageSegmentedControl) + NSLayoutConstraint.activate([ + pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.defaultHigh), + ]) + + pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if !isAdjustBannerImageViewForSafeAreaInset { + isAdjustBannerImageViewForSafeAreaInset = true + profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) + view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + } + +} + +extension ProfileHeaderViewController { + + @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: selectedSegmentIndex: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) + delegate?.profileHeaderViewController(self, pageSegmentedControlValueChanged: sender, selectedSegmentIndex: sender.selectedSegmentIndex) + } + +} + +extension ProfileHeaderViewController { + + func updateHeaderContainerSafeAreaInset(_ inset: UIEdgeInsets) { + containerSafeAreaInset = inset + } + + private func updateHeaderBottomShadow(progress: CGFloat) { + let alpha = min(max(0, 10 * progress - 9), 1) + if bottomShadowAlpha != alpha { + bottomShadowAlpha = alpha + view.setNeedsLayout() + } + } + + func updateHeaderScrollProgress(_ progress: CGFloat) { + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) + updateHeaderBottomShadow(progress: progress) + + let bannerImageView = profileBannerView.bannerImageView + guard bannerImageView.bounds != .zero else { + // wait layout finish + return + } + + let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil) + let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height + + if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { + bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y + bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height + } else if bannerContainerBottomOffset < containerSafeAreaInset.top { + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) + bannerImageView.frame.size.height = bannerImageHeight + } else { + bannerImageView.frame.origin.y = -containerSafeAreaInset.top + bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top + } + + // TODO: handle titleView + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift new file mode 100644 index 000000000..e95697e5c --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -0,0 +1,100 @@ +// +// ProfileFieldView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit +import ActiveLabel + +final class ProfileFieldView: UIView { + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = "Title" + return label + }() + + let valueActiveLabel: ActiveLabel = { + let label = ActiveLabel(style: .profileField) + label.configure(content: "value") + return label + }() + + let topSeparatorLine = UIView.separatorLine + let bottomSeparatorLine = UIView.separatorLine + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFieldView { + private func _init() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor), + titleLabel.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + titleLabel.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + valueActiveLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(valueActiveLabel) + NSLayoutConstraint.activate([ + valueActiveLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + valueActiveLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + valueActiveLabel.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + ]) + valueActiveLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + topSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(topSeparatorLine) + NSLayoutConstraint.activate([ + topSeparatorLine.topAnchor.constraint(equalTo: topAnchor), + topSeparatorLine.leadingAnchor.constraint(equalTo: leadingAnchor), + topSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + topSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + + bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomSeparatorLine) + NSLayoutConstraint.activate([ + bottomSeparatorLine.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomSeparatorLine.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + bottomSeparatorLine.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomSeparatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: self)).priority(.defaultHigh), + ]) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ProfileFieldView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + let filedView = ProfileFieldView() + filedView.valueActiveLabel.configure(field: "https://mastodon.online") + return filedView + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift new file mode 100644 index 000000000..286145ffd --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift @@ -0,0 +1,71 @@ +// +// ProfileFriendshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileFriendshipActionButton: RoundedEdgesButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileFriendshipActionButton { + private func _init() { + configure(state: .follow) + } +} + +extension ProfileFriendshipActionButton { + enum State { + case follow + case following + case blocked + case muted + case edit + case editing + + var title: String { + switch self { + case .follow: return L10n.Common.Controls.Firendship.follow + case .following: return L10n.Common.Controls.Firendship.following + case .blocked: return L10n.Common.Controls.Firendship.blocked + case .muted: return L10n.Common.Controls.Firendship.muted + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + } + } + + var backgroundColor: UIColor { + switch self { + case .follow: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .blocked: return Asset.Colors.Background.danger.color + case .muted: return Asset.Colors.Background.alertYellow.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + } + } + } + + private func configure(state: State) { + setTitle(state.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } +} + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift new file mode 100644 index 000000000..7fac52896 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -0,0 +1,247 @@ +// +// ProfileBannerView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import ActiveLabel + +protocol ProfileHeaderViewDelegate: class { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileHeaderView: UIView { + + static let avatarImageViewSize = CGSize(width: 56, height: 56) + static let avatarImageViewCornerRadius: CGFloat = 6 + static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + + weak var delegate: ProfileHeaderViewDelegate? + + let bannerContainerView = UIView() + let bannerImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.image = .placeholder(color: .systemGray) + imageView.layer.masksToBounds = true + return imageView + }() + + let avatarImageView: UIImageView = { + let imageView = UIImageView() + let placeholderImage = UIImage + .placeholder(size: ProfileHeaderView.avatarImageViewSize, color: Asset.Colors.Background.systemGroupedBackground.color) + .af.imageRounded(withCornerRadius: ProfileHeaderView.avatarImageViewCornerRadius, divideRadiusByImageScale: false) + imageView.image = placeholderImage + return imageView + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = .white + label.text = "Alice" + label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return label + }() + + let usernameLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textColor = .white + label.text = "@alice" + label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return label + }() + + let statusDashboardView = ProfileStatusDashboardView() + let friendshipActionButton = ProfileFriendshipActionButton() + + let bioContainerView = UIView() + let fieldContainerStackView = UIStackView() + + let bioActiveLabel = ActiveLabel(style: .default) + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileHeaderView { + private func _init() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + // banner + bannerContainerView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.preservesSuperviewLayoutMargins = true + addSubview(bannerContainerView) + NSLayoutConstraint.activate([ + bannerContainerView.topAnchor.constraint(equalTo: topAnchor), + bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), + readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width + ]) + + bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + bannerImageView.frame = bannerContainerView.bounds + bannerContainerView.addSubview(bannerImageView) + + // avatar + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.addSubview(avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.leadingAnchor.constraint(equalTo: bannerContainerView.readableContentGuide.leadingAnchor), + bannerContainerView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 20), + avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), + avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), + ]) + + // name container: [display name | username] + let nameContainerStackView = UIStackView() + nameContainerStackView.preservesSuperviewLayoutMargins = true + nameContainerStackView.axis = .vertical + nameContainerStackView.spacing = 0 + nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(nameContainerStackView) + NSLayoutConstraint.activate([ + nameContainerStackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 12), + nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), + ]) + nameContainerStackView.addArrangedSubview(nameLabel) + nameContainerStackView.addArrangedSubview(usernameLabel) + + // meta container: [dashboard container | bio container | field container] + let metaContainerStackView = UIStackView() + metaContainerStackView.spacing = 16 + metaContainerStackView.axis = .vertical + metaContainerStackView.preservesSuperviewLayoutMargins = true + metaContainerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(metaContainerStackView) + NSLayoutConstraint.activate([ + metaContainerStackView.topAnchor.constraint(equalTo: bannerContainerView.bottomAnchor, constant: 13), + metaContainerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + metaContainerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + metaContainerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + // dashboard container: [dashboard | friendship action button] + let dashboardContainerView = UIView() + dashboardContainerView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addArrangedSubview(dashboardContainerView) + + statusDashboardView.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(statusDashboardView) + NSLayoutConstraint.activate([ + statusDashboardView.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + statusDashboardView.leadingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.leadingAnchor), + statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), + ]) + + friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(friendshipActionButton) + NSLayoutConstraint.activate([ + friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + ]) + + bioContainerView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addArrangedSubview(bioContainerView) + bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false + bioContainerView.addSubview(bioActiveLabel) + NSLayoutConstraint.activate([ + bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor), + bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), + bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), + bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), + ]) + + fieldContainerStackView.preservesSuperviewLayoutMargins = true + metaContainerStackView.addSubview(fieldContainerStackView) + + bringSubviewToFront(bannerContainerView) + bringSubviewToFront(nameContainerStackView) + + bioActiveLabel.delegate = self + } + +} + +// MARK: - ActiveLabelDelegate +extension ProfileHeaderView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.profileHeaderView(self, activeLabel: activeLabel, entityDidPressed: entity) + } +} + +// MARK: - ProfileStatusDashboardViewDelegate +extension ProfileHeaderView: ProfileStatusDashboardViewDelegate { + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, postDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followingDashboardMeterViewDidPressed: dashboardMeterView) + } + + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + delegate?.profileHeaderView(self, profileStatusDashboardView: dashboardView, followersDashboardMeterViewDidPressed: dashboardMeterView) + } + +} + +// MARK: - AvatarConfigurableView +extension ProfileHeaderView: AvatarConfigurableView { + static var configurableAvatarImageSize: CGSize { avatarImageViewSize } + static var configurableAvatarImageCornerRadius: CGFloat { avatarImageViewCornerRadius } + var configurableAvatarImageView: UIImageView? { return avatarImageView } + var configurableAvatarButton: UIButton? { return nil } +} + + +#if DEBUG +import SwiftUI + +struct ProfileHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + banner.bannerImageView.image = UIImage(named: "lucas-ludwig") + return banner + } + .previewLayout(.fixed(width: 375, height: 800)) + UIViewPreview(width: 375) { + let banner = ProfileHeaderView() + //banner.bannerImageView.image = UIImage(named: "peter-luo") + return banner + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 800)) + } + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift new file mode 100644 index 000000000..4355fdc3e --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardMeterView.swift @@ -0,0 +1,79 @@ +// +// ProfileStatusDashboardMeterView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileStatusDashboardMeterView: UIView { + + let numberLabel: UILabel = { + let label = UILabel() + label.font = { + let font = UIFont.systemFont(ofSize: 20, weight: .semibold) + return font.fontDescriptor.withDesign(.rounded).flatMap { + UIFont(descriptor: $0, size: 20) + } ?? font + }() + label.textColor = Asset.Colors.Label.primary.color + label.text = "999" + label.textAlignment = .center + return label + }() + + let textLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Profile.Dashboard.posts + label.textAlignment = .center + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardMeterView { + private func _init() { + numberLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(numberLabel) + NSLayoutConstraint.activate([ + numberLabel.topAnchor.constraint(equalTo: topAnchor), + numberLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: numberLabel.trailingAnchor), + ]) + + textLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(textLabel) + NSLayoutConstraint.activate([ + textLabel.topAnchor.constraint(equalTo: numberLabel.bottomAnchor), + textLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: textLabel.trailingAnchor), + bottomAnchor.constraint(equalTo: textLabel.bottomAnchor), + ]) + } +} + +#if DEBUG +import SwiftUI + +struct ProfileStatusDashboardMeterView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 54) { + ProfileStatusDashboardMeterView() + } + .previewLayout(.fixed(width: 54, height: 41)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift new file mode 100644 index 000000000..4a95fb22f --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -0,0 +1,103 @@ +// +// ProfileStatusDashboardView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit + +protocol ProfileStatusDashboardViewDelegate: class { + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) + func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) +} + +final class ProfileStatusDashboardView: UIView { + + let postDashboardMeterView = ProfileStatusDashboardMeterView() + let followingDashboardMeterView = ProfileStatusDashboardMeterView() + let followersDashboardMeterView = ProfileStatusDashboardMeterView() + + weak var delegate: ProfileStatusDashboardViewDelegate? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileStatusDashboardView { + private func _init() { + let containerStackView = UIStackView() + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), + containerStackView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh), + ]) + + let spacing: CGFloat = 16 + containerStackView.spacing = spacing + containerStackView.axis = .horizontal + containerStackView.distribution = .fillEqually + containerStackView.alignment = .top + containerStackView.addArrangedSubview(postDashboardMeterView) + containerStackView.setCustomSpacing(spacing - 2, after: postDashboardMeterView) + containerStackView.addArrangedSubview(followingDashboardMeterView) + containerStackView.setCustomSpacing(spacing + 2, after: followingDashboardMeterView) + containerStackView.addArrangedSubview(followersDashboardMeterView) + + postDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.posts + followingDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.following + followersDashboardMeterView.textLabel.text = L10n.Scene.Profile.Dashboard.followers + + [postDashboardMeterView, followingDashboardMeterView, followersDashboardMeterView].forEach { meterView in + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(ProfileStatusDashboardView.tapGestureRecognizerHandler(_:))) + meterView.addGestureRecognizer(tapGestureRecognizer) + } + + } +} + +extension ProfileStatusDashboardView { + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let sourceView = sender.view as? ProfileStatusDashboardMeterView else { + assertionFailure() + return + } + if sourceView === postDashboardMeterView { + delegate?.profileStatusDashboardView(self, postDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followingDashboardMeterView { + delegate?.profileStatusDashboardView(self, followingDashboardMeterViewDidPressed: sourceView) + } else if sourceView === followersDashboardMeterView { + delegate?.profileStatusDashboardView(self, followersDashboardMeterViewDidPressed: sourceView) + } + } +} + + +#if DEBUG +import SwiftUI + +struct ProfileBannerStatusView_Previews: PreviewProvider { + static var previews: some View { + UIViewPreview(width: 375) { + ProfileStatusDashboardView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } +} +#endif diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift new file mode 100644 index 000000000..36bcf0b4e --- /dev/null +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -0,0 +1,33 @@ +// +// MeProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class MeProfileViewModel: ProfileViewModel { + + init(context: AppContext) { + super.init( + context: context, + optionalMastodonUser: context.authenticationService.activeMastodonAuthentication.value?.user + ) + + self.currentMastodonUser + .sink { [weak self] currentMastodonUser in + os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + + guard let self = self else { return } + self.mastodonUser.value = currentMastodonUser + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index b3c46a42d..4b74ad632 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -5,20 +5,672 @@ // Created by MainasuK Cirno on 2021-2-23. // +import os.log import UIKit +import Combine +import ActiveLabel final class ProfileViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var viewModel: ProfileViewModel! + + private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent { + didSet { + setNeedsStatusBarAppearanceUpdate() + } + } + + let refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.tintColor = .label + return refreshControl + }() + + let containerScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.scrollsToTop = false + scrollView.showsVerticalScrollIndicator = false + scrollView.preservesSuperviewLayoutMargins = true + scrollView.delaysContentTouches = false + return scrollView + }() + + let overlayScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.backgroundColor = .clear + scrollView.delaysContentTouches = false + return scrollView + }() + + private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() + private(set) lazy var profileHeaderViewController = ProfileHeaderViewController() + private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! + + private var contentOffsets: [Int: CGFloat] = [:] + var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + + + deinit { + os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ProfileViewController { - override func viewDidLoad() { - super.viewDidLoad() - + func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { + updateOverlayScrollViewContentSize(scrollView: scrollView) + return scrollView.observe(\.contentSize, options: .new) { scrollView, change in + self.updateOverlayScrollViewContentSize(scrollView: scrollView) + } + } + + func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { + let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) + let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height + let contentSize = CGSize( + width: self.containerScrollView.contentSize.width, + height: bottomPageHeight + headerViewHeight + ) + self.overlayScrollView.contentSize = contentSize + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) } } + +extension ProfileViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return preferredStatusBarStyleForBanner + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + navigationItem.titleView = UIView() + +// if navigationController?.viewControllers.first == self { +// navigationItem.leftBarButtonItem = avatarBarButtonItem +// } +// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) + +// unmuteMenuBarButtonItem.target = self +// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:)) + +// Publishers.CombineLatest4( +// viewModel.muted.eraseToAnyPublisher(), +// viewModel.blocked.eraseToAnyPublisher(), +// viewModel.twitterUser.eraseToAnyPublisher(), +// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in +// guard let self = self else { return } +// guard let twitterUser = twitterUser, +// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox, +// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else { +// self.navigationItem.rightBarButtonItems = [] +// return +// } +// +// if #available(iOS 14.0, *) { +// self.moreMenuBarButtonItem.target = nil +// self.moreMenuBarButtonItem.action = nil +// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser( +// twitterUser: twitterUser, +// muted: muted, +// blocked: blocked, +// dependency: self +// ) +// } else { +// // no menu supports for early version +// self.moreMenuBarButtonItem.target = self +// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:)) +// } +// +// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem] +// if muted { +// rightBarButtonItems.append(self.unmuteMenuBarButtonItem) +// } +// +// self.navigationItem.rightBarButtonItems = rightBarButtonItems +// } +// .store(in: &disposeBag) + + overlayScrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) + +// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self) + + let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) + viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag) + + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) + viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag) + + let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) + viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag) + + profileSegmentedViewController.pagingViewController.viewModel = { + let profilePagingViewModel = ProfilePagingViewModel( + postsUserTimelineViewModel: postsUserTimelineViewModel, + repliesUserTimelineViewModel: repliesUserTimelineViewModel, + mediaUserTimelineViewModel: mediaUserTimelineViewModel + ) + profilePagingViewModel.viewControllers.forEach { viewController in + if let viewController = viewController as? NeedsDependency { + viewController.context = context + viewController.coordinator = coordinator + } + } + return profilePagingViewModel + }() + + profileHeaderViewController.pageSegmentedControl.removeAllSegments() + profileSegmentedViewController.pagingViewController.viewModel.barItems.forEach { item in + let index = profileHeaderViewController.pageSegmentedControl.numberOfSegments + profileHeaderViewController.pageSegmentedControl.insertSegment(withTitle: item.title, at: index, animated: false) + } + profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = 0 + + overlayScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(overlayScrollView) + NSLayoutConstraint.activate([ + overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), + overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + containerScrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerScrollView) + NSLayoutConstraint.activate([ + containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), + view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), + containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + // add segmented list + addChild(profileSegmentedViewController) + profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileSegmentedViewController.view) + profileSegmentedViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), + profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), + profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), + ]) + + // add header + addChild(profileHeaderViewController) + profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false + containerScrollView.addSubview(profileHeaderViewController.view) + profileHeaderViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), + profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), + containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), + profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), + ]) + + containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) + overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most + overlayScrollView.delegate = self + profileHeaderViewController.delegate = self + profileSegmentedViewController.pagingViewController.pagingDelegate = self + +// // add segmented bar to header +// profileSegmentedViewController.pagingViewController.addBar( +// bar, +// dataSource: profileSegmentedViewController.pagingViewController.viewModel, +// at: .custom(view: profileHeaderViewController.view, layout: { bar in +// bar.translatesAutoresizingMaskIntoConstraints = false +// self.profileHeaderViewController.view.addSubview(bar) +// NSLayoutConstraint.activate([ +// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), +// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), +// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), +// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh), +// ]) +// }) +// ) + + // bind view model + Publishers.CombineLatest( + viewModel.bannerImageURL.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] bannerImageURL, _ in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color) + guard let bannerImageURL = bannerImageURL else { + self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder + return + } + self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage( + withURL: bannerImageURL, + placeholderImage: placeholder, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .success(let image): + self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark + case .failure: + break + } + } + ) + } + .store(in: &disposeBag) + viewModel.headerDomainLumaStyle + .receive(on: DispatchQueue.main) + .sink { [weak self] style in + guard let self = self else { return } + let textColor: UIColor + let shadowColor: UIColor + switch style { + case .light: + self.preferredStatusBarStyleForBanner = .darkContent + textColor = .black + shadowColor = .white + case .dark: + self.preferredStatusBarStyleForBanner = .lightContent + textColor = .white + shadowColor = .black + default: + self.preferredStatusBarStyleForBanner = .default + textColor = .white + shadowColor = .black + } + + self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor + self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor + self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) + self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) + } + .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.avatarImageURL.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] avatarImageURL, _ in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.configure( + with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL) + ) + } + .store(in: &disposeBag) +// viewModel.protected +// .map { $0 != true } +// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView) +// .store(in: &disposeBag) + viewModel.name + .map { $0 ?? " " } + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel) + .store(in: &disposeBag) + viewModel.username + .map { username in username.flatMap { "@" + $0 } ?? " " } + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel) + .store(in: &disposeBag) +// viewModel.friendship +// .sink { [weak self] friendship in +// guard let self = self else { return } +// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton +// followingButton.isHidden = friendship == nil +// +// if let friendship = friendship { +// switch friendship { +// case .following: followingButton.style = .following +// case .pending: followingButton.style = .pending +// case .none: followingButton.style = .follow +// } +// } +// } +// .store(in: &disposeBag) +// viewModel.followedBy +// .sink { [weak self] followedBy in +// guard let self = self else { return } +// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel +// followStatusLabel.isHidden = followedBy != true +// } +// .store(in: &disposeBag) +// + viewModel.bioDescription + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] bio in + guard let self = self else { return } + self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "") + }) + .store(in: &disposeBag) +// Publishers.CombineLatest( +// viewModel.url.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] url, isSuspended in +// guard let self = self else { return } +// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " +// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal) +// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended +// } +// .store(in: &disposeBag) +// Publishers.CombineLatest( +// viewModel.location.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] location, isSuspended in +// guard let self = self else { return } +// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " +// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal) +// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended +// } +// .store(in: &disposeBag) + viewModel.statusesCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) + viewModel.followingCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) + viewModel.followersCount + .sink { [weak self] count in + guard let self = self else { return } + let text = count.flatMap { String($0) } ?? "-" + self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + } + .store(in: &disposeBag) +// viewModel.followersCount +// .sink { [weak self] count in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" +// } +// .store(in: &disposeBag) +// viewModel.listedCount +// .sink { [weak self] count in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" +// } +// .store(in: &disposeBag) +// viewModel.suspended +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isSuspended in +// guard let self = self else { return } +// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended +// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended +// if isSuspended { +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileTweetPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserTimelineViewModel.State.Suspended.self) +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileMediaPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserMediaTimelineViewModel.State.Suspended.self) +// self.profileSegmentedViewController +// .pagingViewController.viewModel +// .profileLikesPostTimelineViewController.viewModel +// .stateMachine +// .enter(UserLikeTimelineViewModel.State.Suspended.self) +// } +// } +// .store(in: &disposeBag) + +// + profileHeaderViewController.profileBannerView.delegate = self + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppear.send() + + // set overlay scroll view initial content size + guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer else { return } + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: currentViewController.scrollView) + currentViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + currentPostTimelineTableViewContentSizeObservation = nil + } + +} + +extension ProfileViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController + if let currentViewController = currentViewController as? UserTimelineViewController { + currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } + } + +// @objc private func avatarButtonPressed(_ sender: UIButton) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) +// } +// +// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// guard let twitterUser = viewModel.twitterUser.value else { +// assertionFailure() +// return +// } +// +// UserProviderFacade.toggleMuteUser( +// context: context, +// twitterUser: twitterUser, +// muted: viewModel.muted.value +// ) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &disposeBag) +// } +// +// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) { +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) +// guard let twitterUser = viewModel.twitterUser.value else { +// assertionFailure() +// return +// } +// +// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser( +// twitterUser: twitterUser, +// muted: viewModel.muted.value, +// blocked: viewModel.blocked.value, +// sender: sender, +// dependency: self +// ) +// present(moreMenuAlertController, animated: true, completion: nil) +// } + +} + +// MARK: - UIScrollViewDelegate +extension ProfileViewController: UIScrollViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y + let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top + if scrollView.contentOffset.y < topMaxContentOffsetY { + self.containerScrollView.contentOffset.y = scrollView.contentOffset.y + for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { + postTimelineView.scrollView.contentOffset.y = 0 + } + contentOffsets.removeAll() + } else { + containerScrollView.contentOffset.y = topMaxContentOffsetY + if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { + let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y + customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + } + } + + // elastically banner image + let headerScrollProgress = containerScrollView.contentOffset.y / topMaxContentOffsetY + profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress) + } + +} + +// MARK: - ProfileHeaderViewControllerDelegate +extension ProfileViewController: ProfileHeaderViewControllerDelegate { + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { + guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { + // assertionFailure() + return + } + + updateOverlayScrollViewContentSize(scrollView: scrollView) + } + + func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) { + profileSegmentedViewController.pagingViewController.scrollToPage( + .at(index: index), + animated: true + ) + } + +} + +// MARK: - ProfilePagingViewControllerDelegate +extension ProfileViewController: ProfilePagingViewControllerDelegate { + + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { + os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) + + // save content offset + overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y + + // setup observer and gesture fallback + currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) + postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) + + +// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController, +// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState { +// switch currentState { +// case is UserMediaTimelineViewModel.State.NoMore, +// is UserMediaTimelineViewModel.State.NotAuthorized, +// is UserMediaTimelineViewModel.State.Blocked: +// break +// default: +// if userMediaTimelineViewController.viewModel.items.value.isEmpty { +// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self) +// } +// } +// } +// +// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController, +// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState { +// switch currentState { +// case is UserLikeTimelineViewModel.State.NoMore, +// is UserLikeTimelineViewModel.State.NotAuthorized, +// is UserLikeTimelineViewModel.State.Blocked: +// break +// default: +// if userLikeTimelineViewController.viewModel.items.value.isEmpty { +// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self) +// } +// } +// } + } + +} + +// MARK: - ProfileBannerInfoActionViewDelegate +//extension ProfileViewController: ProfileBannerInfoActionViewDelegate { +// +// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) { +// UserProviderFacade +// .toggleUserFriendship(provider: self, sender: button) +// .sink { _ in +// // do nothing +// } receiveValue: { _ in +// // do nothing +// } +// .store(in: &disposeBag) +// } +// +//} + +// MARK: - ProfileHeaderViewDelegate +extension ProfileViewController: ProfileHeaderViewDelegate { + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dwersDashboardMeterView: ProfileStatusDashboardMeterView) { + + } + +} + +// MARK: - ScrollViewContainer +extension ProfileViewController: ScrollViewContainer { + var scrollView: UIScrollView { return overlayScrollView } +} diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift new file mode 100644 index 000000000..f7248009d --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -0,0 +1,197 @@ +// +// ProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +// please override this base class +class ProfileViewModel: NSObject { + + typealias UserID = String + + var disposeBag = Set() + var observations = Set() + private var mastodonUserObserver: AnyCancellable? + private var currentMastodonUserObserver: AnyCancellable? + + // input + let context: AppContext + let mastodonUser: CurrentValueSubject + let currentMastodonUser = CurrentValueSubject(nil) + let viewDidAppear = PassthroughSubject() + let headerDomainLumaStyle = CurrentValueSubject(.dark) // default dark for placeholder banner + + // output + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let bannerImageURL: CurrentValueSubject + let avatarImageURL: CurrentValueSubject +// let protected: CurrentValueSubject + let name: CurrentValueSubject + let username: CurrentValueSubject + let bioDescription: CurrentValueSubject + let url: CurrentValueSubject + let statusesCount: CurrentValueSubject + let followingCount: CurrentValueSubject + let followersCount: CurrentValueSubject + +// let friendship: CurrentValueSubject +// let followedBy: CurrentValueSubject +// let muted: CurrentValueSubject +// let blocked: CurrentValueSubject +// +// let suspended = CurrentValueSubject(false) +// + + init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { + self.context = context + self.mastodonUser = CurrentValueSubject(mastodonUser) + self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) + self.userID = CurrentValueSubject(mastodonUser?.id) + self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) + self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) +// self.protected = CurrentValueSubject(twitterUser?.protected) + self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) + self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) + self.bioDescription = CurrentValueSubject(mastodonUser?.note) + self.url = CurrentValueSubject(mastodonUser?.url) + self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) + self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) + self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) +// self.friendship = CurrentValueSubject(nil) +// self.followedBy = CurrentValueSubject(nil) +// self.muted = CurrentValueSubject(false) +// self.blocked = CurrentValueSubject(false) + super.init() + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.domain.value = nil + self.currentMastodonUser.value = nil + return + } + self.domain.value = activeMastodonAuthentication.domain + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + + setup() + } + +} + +extension ProfileViewModel { + + enum Friendship: CustomDebugStringConvertible { + case following + case pending + case none + + var debugDescription: String { + switch self { + case .following: return "following" + case .pending: return "pending" + case .none: return "none" + } + } + } + +} + +extension ProfileViewModel { + private func setup() { + Publishers.CombineLatest( + mastodonUser.eraseToAnyPublisher(), + currentMastodonUser.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] mastodonUser, currentMastodonUser in + guard let self = self else { return } + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + + if let mastodonUser = mastodonUser { + // setup observer + self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser) + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + + } else { + self.mastodonUserObserver = nil + } + + if let currentMastodonUser = currentMastodonUser { + // setup observer + self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { completion in + switch completion { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .finished: + assertionFailure() + } + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard let changeType = change.changeType else { return } + switch changeType { + case .update: + self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + case .delete: + // TODO: + break + } + } + } else { + self.currentMastodonUserObserver = nil + } + } + .store(in: &disposeBag) + } + + private func update(mastodonUser: MastodonUser?) { + self.userID.value = mastodonUser?.id + self.bannerImageURL.value = mastodonUser?.headerImageURL() + self.avatarImageURL.value = mastodonUser?.avatarImageURL() +// self.protected.value = twitterUser?.protected + self.name.value = mastodonUser?.displayNameWithFallback + self.username.value = mastodonUser?.acctWithDomain + self.bioDescription.value = mastodonUser?.note + self.url.value = mastodonUser?.url + self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } + self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } + self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + } + + private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { + // TODO: + } + + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift new file mode 100644 index 000000000..568369d66 --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -0,0 +1,47 @@ +// +// ProfilePagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +protocol ProfilePagingViewControllerDelegate: class { + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) +} + +final class ProfilePagingViewController: TabmanViewController { + + weak var pagingDelegate: ProfilePagingViewControllerDelegate? + var viewModel: ProfilePagingViewModel! + + + // MARK: - PageboyViewControllerDelegate + + override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { + super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) + + let viewController = viewModel.viewControllers[index] + pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfilePagingViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + dataSource = viewModel + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift new file mode 100644 index 000000000..252d5e14f --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -0,0 +1,68 @@ +// +// ProfilePagingViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Pageboy +import Tabman + +final class ProfilePagingViewModel: NSObject { + + let postUserTimelineViewController = UserTimelineViewController() + let repliesUserTimelineViewController = UserTimelineViewController() + let mediaUserTimelineViewController = UserTimelineViewController() + + init( + postsUserTimelineViewModel: UserTimelineViewModel, + repliesUserTimelineViewModel: UserTimelineViewModel, + mediaUserTimelineViewModel: UserTimelineViewModel + ) { + postUserTimelineViewController.viewModel = postsUserTimelineViewModel + repliesUserTimelineViewController.viewModel = repliesUserTimelineViewModel + mediaUserTimelineViewController.viewModel = mediaUserTimelineViewModel + super.init() + } + + var viewControllers: [ScrollViewContainer] { + return [ + postUserTimelineViewController, + repliesUserTimelineViewController, + mediaUserTimelineViewController, + ] + } + + let barItems: [TMBarItemable] = { + let items = [ + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.replies), + TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), + ] + return items + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - PageboyViewControllerDataSource +extension ProfilePagingViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + return viewControllers[index] + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .first + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift new file mode 100644 index 000000000..06eaab3f4 --- /dev/null +++ b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift @@ -0,0 +1,38 @@ +// +// ProfileSegmentedViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit + +final class ProfileSegmentedViewController: UIViewController { + let pagingViewController = ProfilePagingViewController() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } +} + +extension ProfileSegmentedViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + + addChild(pagingViewController) + pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pagingViewController.view) + pagingViewController.didMove(toParent: self) + NSLayoutConstraint.activate([ + pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift new file mode 100644 index 000000000..1ea164406 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift @@ -0,0 +1,87 @@ +// +// UserTimelineViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension UserTimelineViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .status(let objectID, _): + let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift new file mode 100644 index 000000000..88134f1e1 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -0,0 +1,145 @@ +// +// UserTimelineViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreDataStack +import GameplayKit + +// TODO: adopt MediaPreviewableViewController +final class UserTimelineViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserTimelineViewModel! + + // let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + // trigger user timeline loading + Publishers.CombineLatest( + viewModel.domain.removeDuplicates().eraseToAnyPublisher(), + viewModel.userID.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + .store(in: &disposeBag) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + context.videoPlaybackService.viewDidDisappear(from: self) + } + +} + +// MARK: - UIScrollViewDelegate +extension UserTimelineViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +// MARK: - UITableViewDelegate +extension UserTimelineViewController: UITableViewDelegate { + + // TODO: cache cell height + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 200 + } + +} + +// MARK: - AVPlayerViewControllerDelegate +extension UserTimelineViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - TimelinePostTableViewCellDelegate +extension UserTimelineViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +//// MARK: - TimelineHeaderTableViewCellDelegate +//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { } + + +// MARK: - CustomScrollViewContainerController +extension UserTimelineViewController: ScrollViewContainer { + var scrollView: UIScrollView { return tableView } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = UserTimelineViewModel.State.LoadingMore + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift new file mode 100644 index 000000000..1a09e1b35 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// UserTimelineViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +extension UserTimelineViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } + +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift new file mode 100644 index 000000000..520fa43e5 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -0,0 +1,262 @@ +// +// UserTimelineViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension UserTimelineViewModel { + class State: GKState { + weak var viewModel: UserTimelineViewModel? + + init(viewModel: UserTimelineViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension UserTimelineViewModel.State { + class Initial: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.userID.value != nil + case is Suspended.Type: + return true + default: + return false + } + } + } + + class Reloading: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let domain = activeMastodonAuthenticationBox.domain + let queryFilter = viewModel.queryFilter.value + + viewModel.context.apiService.userTimeline( + domain: domain, + accountID: userID, + maxID: nil, + sinceID: nil, + excludeReplies: queryFilter.excludeReplies, + excludeReblogs: queryFilter.excludeReblogs, + onlyMedia: queryFilter.onlyMedia, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is LoadingMore.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } + + class Idle: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is LoadingMore.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } + + class LoadingMore: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else { + stateMachine.enter(Fail.self) + return + } + + guard let userID = viewModel.userID.value, !userID.isEmpty else { + stateMachine.enter(Fail.self) + return + } + + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + let domain = activeMastodonAuthenticationBox.domain + let queryFilter = viewModel.queryFilter.value + + viewModel.context.apiService.userTimeline( + domain: domain, + accountID: userID, + maxID: maxID, + sinceID: nil, + excludeReplies: queryFilter.excludeReplies, + excludeReblogs: queryFilter.excludeReblogs, + onlyMedia: queryFilter.onlyMedia, + authorizationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class NotAuthorized: UserTimelineViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + } + + class Blocked: UserTimelineViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + + } + + class Suspended: UserTimelineViewModel.State { + + } + + class NoMore: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + case is NotAuthorized.Type, is Blocked.Type: + return true + case is Suspended.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift new file mode 100644 index 000000000..de17376d2 --- /dev/null +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -0,0 +1,127 @@ +// +// UserTimelineViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import GameplayKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import AlamofireImage + +class UserTimelineViewModel: NSObject { + + var disposeBag = Set() + + // input + let context: AppContext + let domain: CurrentValueSubject + let userID: CurrentValueSubject + let queryFilter: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.LoadingMore(viewModel: self), + State.NotAuthorized(viewModel: self), + State.Blocked(viewModel: self), + State.Suspended(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + init(context: AppContext, domain: String?, userID: String?, queryFilter: QueryFilter) { + self.context = context + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + self.domain = CurrentValueSubject(domain) + self.userID = CurrentValueSubject(userID) + self.queryFilter = CurrentValueSubject(queryFilter) + super.init() + + self.domain + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + + statusFetchedResultsController.items + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + // var isPermissionDenied = false + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + var items: [Item] = [] + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + // TODO: handle other states + default: + break + } + } + + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + .store(in: &disposeBag) + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension UserTimelineViewModel { + struct QueryFilter { + let excludeReplies: Bool? + let excludeReblogs: Bool? + let onlyMedia: Bool? + + init( + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil + ) { + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + } + } + +} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index 7d52a5764..a92b8f37e 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -15,11 +15,11 @@ import MastodonSDK // MARK: - StatusProvider extension PublicTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -33,11 +33,11 @@ extension PublicTimelineViewController: StatusProvider { } switch item { - case .toot(let objectID, _): + case .status(let objectID, _): let managedObjectContext = self.viewModel.fetchedResultsController.managedObjectContext managedObjectContext.perform { - let toot = managedObjectContext.object(with: objectID) as? Toot - promise(.success(toot)) + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) } default: promise(.success(nil)) @@ -45,7 +45,7 @@ extension PublicTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 8e954c053..844c43cd8 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -159,13 +159,13 @@ extension PublicTimelineViewController: LoadMoreConfigurableTableViewContainer { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { - guard let upperTimelineTootID = upperTimelineTootID else {return} + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { + guard let upperTimelineStatusID = upperTimelineStatusID else {return} viewModel.loadMiddleSateMachineList .receive(on: DispatchQueue.main) .sink { [weak self] ids in guard let _ = self else { return } - if let stateMachine = ids[upperTimelineTootID] { + if let stateMachine = ids[upperTimelineStatusID] { guard let state = stateMachine.currentState else { assertionFailure() return @@ -185,17 +185,17 @@ extension PublicTimelineViewController: TimelineMiddleLoaderTableViewCellDelegat .store(in: &cell.disposeBag) var dict = viewModel.loadMiddleSateMachineList.value - if let _ = dict[upperTimelineTootID] { + if let _ = dict[upperTimelineStatusID] { // do nothing } else { let stateMachine = GKStateMachine(states: [ - PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), - PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineTootID: upperTimelineTootID), + PublicTimelineViewModel.LoadMiddleState.Initial(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Loading(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Fail(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), + PublicTimelineViewModel.LoadMiddleState.Success(viewModel: viewModel, upperTimelineStatusID: upperTimelineStatusID), ]) stateMachine.enter(PublicTimelineViewModel.LoadMiddleState.Initial.self) - dict[upperTimelineTootID] = stateMachine + dict[upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index d69da8f65..ce0e8b19d 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -41,32 +41,32 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - let indexes = tootIDs.value - let toots = fetchedResultsController.fetchedObjects ?? [] - guard toots.count == indexes.count else { return } - let indexTootTuples: [(Int, Toot)] = toots - .compactMap { toot -> (Int, Toot)? in - guard toot.deletedAt == nil else { return nil } - return indexes.firstIndex(of: toot.id).map { index in (index, toot) } + let indexes = statusIDs.value + let statuses = fetchedResultsController.fetchedObjects ?? [] + guard statuses.count == indexes.count else { return } + let indexStatusTuples: [(Int, Status)] = statuses + .compactMap { status -> (Int, Status)? in + guard status.deletedAt == nil else { return nil } + return indexes.firstIndex(of: status.id).map { index in (index, status) } } .sorted { $0.0 < $1.0 } var oldSnapshotAttributeDict: [NSManagedObjectID: Item.StatusAttribute] = [:] for item in self.items.value { - guard case let .toot(objectID, attribute) = item else { continue } + guard case let .status(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute } var items = [Item]() - for (_, toot) in indexTootTuples { - let targetToot = toot.reblog ?? toot + for (_, status) in indexStatusTuples { + let targetStatus = status.reblog ?? status let isStatusTextSensitive: Bool = { - guard let spoilerText = targetToot.spoilerText, !spoilerText.isEmpty else { return false } + guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[toot.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetToot.sensitive) - items.append(Item.toot(objectID: toot.objectID, attribute: attribute)) - if tootIDsWhichHasGap.contains(toot.id) { - items.append(Item.publicMiddleLoader(tootID: toot.id)) + let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive) + items.append(Item.status(objectID: status.objectID, attribute: attribute)) + if statusIDsWhichHasGap.contains(status.id) { + items.append(Item.publicMiddleLoader(statusID: status.id)) } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift index 62334c746..4727072bf 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+LoadMiddleState.swift @@ -14,18 +14,18 @@ import os.log extension PublicTimelineViewModel { class LoadMiddleState: GKState { weak var viewModel: PublicTimelineViewModel? - let upperTimelineTootID: String + let upperTimelineStatusID: String - init(viewModel: PublicTimelineViewModel, upperTimelineTootID: String) { + init(viewModel: PublicTimelineViewModel, upperTimelineStatusID: String) { self.viewModel = viewModel - self.upperTimelineTootID = upperTimelineTootID + self.upperTimelineStatusID = upperTimelineStatusID } override func didEnter(from previousState: GKState?) { os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, self.debugDescription, previousState.debugDescription) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } var dict = viewModel.loadMiddleSateMachineList.value - dict[self.upperTimelineTootID] = stateMachine + dict[self.upperTimelineStatusID] = stateMachine viewModel.loadMiddleSateMachineList.value = dict // trigger value change } } @@ -54,42 +54,42 @@ extension PublicTimelineViewModel.LoadMiddleState { } viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, - maxID: upperTimelineTootID + maxID: upperTimelineStatusID ) .receive(on: DispatchQueue.main) .sink { completion in switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break } } receiveValue: { response in - let toots = response.value - let addedToots = toots.filter { !viewModel.tootIDs.value.contains($0.id) } + let statuses = response.value + let addedStatuses = statuses.filter { !viewModel.statusIDs.value.contains($0.id) } - guard let gapIndex = viewModel.tootIDs.value.firstIndex(of: self.upperTimelineTootID) else { return } - let upToots = Array(viewModel.tootIDs.value[...gapIndex]) - let downToots = Array(viewModel.tootIDs.value[(gapIndex + 1)...]) + guard let gapIndex = viewModel.statusIDs.value.firstIndex(of: self.upperTimelineStatusID) else { return } + let upStatuses = Array(viewModel.statusIDs.value[...gapIndex]) + let downStatuses = Array(viewModel.statusIDs.value[(gapIndex + 1)...]) - // construct newTootIDs - var newTootIDs = upToots - newTootIDs.append(contentsOf: addedToots.map { $0.id }) - newTootIDs.append(contentsOf: downToots) + // construct newStatusIDs + var newStatusIDs = upStatuses + newStatusIDs.append(contentsOf: addedStatuses.map { $0.id }) + newStatusIDs.append(contentsOf: downStatuses) // remove old gap from viewmodel - if let index = viewModel.tootIDsWhichHasGap.firstIndex(of: self.upperTimelineTootID) { - viewModel.tootIDsWhichHasGap.remove(at: index) + if let index = viewModel.statusIDsWhichHasGap.firstIndex(of: self.upperTimelineStatusID) { + viewModel.statusIDsWhichHasGap.remove(at: index) } // add new gap from viewmodel if need - let intersection = toots.filter { downToots.contains($0.id) } + let intersection = statuses.filter { downStatuses.contains($0.id) } if intersection.isEmpty { - addedToots.last.flatMap { viewModel.tootIDsWhichHasGap.append($0.id) } + addedStatuses.last.flatMap { viewModel.statusIDsWhichHasGap.append($0.id) } } - viewModel.tootIDs.value = newTootIDs - os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld toots, %{public}%ld new toots", (#file as NSString).lastPathComponent, #line, #function, toots.count, addedToots.count) - if addedToots.isEmpty { + viewModel.statusIDs.value = newStatusIDs + os_log("%{public}s[%{public}ld], %{public}s: load %{public}ld statuses, %{public}%ld new statues", (#file as NSString).lastPathComponent, #line, #function, statuses.count, addedStatuses.count) + if addedStatuses.isEmpty { stateMachine.enter(Fail.self) } else { stateMachine.enter(Success.self) diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift index 258db0cdd..c165adb70 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+State.swift @@ -68,21 +68,21 @@ extension PublicTimelineViewModel.State { break } } receiveValue: { response in - let resposeTootIDs = response.value.compactMap { $0.id } - var newTootsIDs = resposeTootIDs - let oldTootsIDs = viewModel.tootIDs.value + let resposeStatusIDs = response.value.compactMap { $0.id } + var newStatusIDs = resposeStatusIDs + let oldStatusIDs = viewModel.statusIDs.value var hasGap = true - for tootID in oldTootsIDs { - if !newTootsIDs.contains(tootID) { - newTootsIDs.append(tootID) + for statusID in oldStatusIDs { + if !newStatusIDs.contains(statusID) { + newStatusIDs.append(statusID) } else { hasGap = false } } - if hasGap && oldTootsIDs.count > 0 { - resposeTootIDs.last.flatMap { viewModel.tootIDsWhichHasGap.append($0) } + if hasGap && oldStatusIDs.count > 0 { + resposeStatusIDs.last.flatMap { viewModel.statusIDsWhichHasGap.append($0) } } - viewModel.tootIDs.value = newTootsIDs + viewModel.statusIDs.value = newStatusIDs stateMachine.enter(Idle.self) } .store(in: &viewModel.disposeBag) @@ -138,7 +138,7 @@ extension PublicTimelineViewModel.State { stateMachine.enter(Fail.self) return } - let maxID = viewModel.tootIDs.value.last + let maxID = viewModel.statusIDs.value.last viewModel.context.apiService.publicTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID @@ -153,14 +153,14 @@ extension PublicTimelineViewModel.State { } } receiveValue: { response in stateMachine.enter(Idle.self) - var oldTootsIDs = viewModel.tootIDs.value - for toot in response.value { - if !oldTootsIDs.contains(toot.id) { - oldTootsIDs.append(toot.id) + var oldStatusIDs = viewModel.statusIDs.value + for status in response.value { + if !oldStatusIDs.contains(status.id) { + oldStatusIDs.append(status.id) } } - viewModel.tootIDs.value = oldTootsIDs + viewModel.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift index d7d6448a5..c3b1a3d4b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel.swift @@ -19,7 +19,7 @@ class PublicTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) @@ -31,7 +31,7 @@ class PublicTimelineViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? // - var tootIDsWhichHasGap = [String]() + var statusIDsWhichHasGap = [String]() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,15 +47,15 @@ class PublicTimelineViewModel: NSObject { return stateMachine }() - let tootIDs = CurrentValueSubject<[String], Never>([]) + let statusIDs = CurrentValueSubject<[String], Never>([]) let items = CurrentValueSubject<[Item], Never>([]) var cellFrameCache = NSCache() init(context: AppContext) { self.context = context self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest - fetchRequest.predicate = Toot.predicate(domain: "", ids: []) + let fetchRequest = Status.sortedFetchRequest + fetchRequest.predicate = Status.predicate(domain: "", ids: []) fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( @@ -111,12 +111,12 @@ class PublicTimelineViewModel: NSObject { } .store(in: &disposeBag) - tootIDs + statusIDs .receive(on: DispatchQueue.main) .sink { [weak self] ids in guard let self = self else { return } let domain = self.context.authenticationService.activeMastodonAuthenticationBox.value?.domain ?? "" - self.fetchedResultsController.fetchRequest.predicate = Toot.predicate(domain: domain, ids: ids) + self.fetchedResultsController.fetchRequest.predicate = Status.predicate(domain: domain, ids: ids) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift new file mode 100644 index 000000000..8ba7c2257 --- /dev/null +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -0,0 +1,16 @@ +// +// AdaptiveStatusBarStyleNavigationController.swift +// +// +// Created by MainasuK Cirno on 2021-2-26. +// + +import UIKit + +// Make status bar style adptive for child view controller +// SeeAlso: `modalPresentationCapturesStatusBarAppearance` +final class AdaptiveStatusBarStyleNavigationController: UINavigationController { + override var childForStatusBarStyle: UIViewController? { + return visibleViewController + } +} diff --git a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift deleted file mode 100644 index 0fa4a0e20..000000000 --- a/Mastodon/Scene/Share/NavigationController/DarkContentStatusBarStyleNavigationController.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// DarkContentStatusBarStyleNavigationController.swift -// -// -// Created by MainasuK Cirno on 2021-2-26. -// - -import UIKit - -final class DarkContentStatusBarStyleNavigationController: UINavigationController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } -} diff --git a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift index a38b711dd..266fa6594 100644 --- a/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift +++ b/Mastodon/Scene/Share/View/Button/RoundedEdgesButton.swift @@ -7,7 +7,7 @@ import UIKit -final class RoundedEdgesButton: UIButton { +class RoundedEdgesButton: UIButton { override func layoutSubviews() { super.layoutSubviews() diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 6d7800b04..b4105a4d2 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -12,6 +12,8 @@ import ActiveLabel import AlamofireImage protocol StatusViewDelegate: class { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) @@ -195,8 +197,9 @@ final class StatusView: UIView { return actionToolbarContainer }() - let activeTextLabel = ActiveLabel(style: .default) + + private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer override init(frame: CGRect) { super.init(frame: frame) @@ -226,7 +229,7 @@ final class StatusView: UIView { extension StatusView { func _init() { - // container: [retoot | author | status | action toolbar] + // container: [reblog | author | status | action toolbar] let containerStackView = UIStackView() containerStackView.axis = .vertical containerStackView.spacing = 10 @@ -401,6 +404,12 @@ extension StatusView { playerContainerView.delegate = self + headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) + headerInfoLabel.isUserInteractionEnabled = true + headerInfoLabel.addGestureRecognizer(headerInfoLabelTapGestureRecognizer) + + avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside) + avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } @@ -439,6 +448,21 @@ extension StatusView { extension StatusView { + @objc private func headerInfoLabelTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, headerInfoLabelDidPressed: headerInfoLabel) + } + + @objc private func avatarButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, avatarButtonDidPressed: sender) + } + + @objc private func avatarStackedContainerButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.statusView(self, avatarButtonDidPressed: sender) + } + @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.statusView(self, contentWarningActionButtonPressed: sender) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 5f9bdf654..9c954e505 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -20,6 +20,8 @@ protocol StatusTableViewCellDelegate: class { var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) @@ -194,6 +196,14 @@ extension StatusTableViewCell: UITableViewDelegate { // MARK: - StatusViewDelegate extension StatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + delegate?.statusTableViewCell(self, statusView: statusView, headerInfoLabelDidPressed: label) + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button) + } + func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 597420481..75c06a339 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -11,7 +11,7 @@ import os.log import UIKit protocol TimelineMiddleLoaderTableViewCellDelegate: class { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID:NSManagedObjectID?) + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID:NSManagedObjectID?) func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 2eacff573..d49a7371b 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -17,29 +17,29 @@ extension APIService { // make local state change only func like( - tootObjectID: NSManagedObjectID, + statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, favoriteKind: Mastodon.API.Favorites.FavoriteKind - ) -> AnyPublisher { - var _targetTootID: Toot.ID? + ) -> AnyPublisher { + var _targetStatusID: Status.ID? let managedObjectContext = backgroundManagedObjectContext return managedObjectContext.performChanges { - let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let status = managedObjectContext.object(with: statusObjectID) as! Status let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetToot = toot.reblog ?? toot - let targetTootID = targetToot.id - _targetTootID = targetTootID + let targetStatus = status.reblog ?? status + let targetStatusID = targetStatus.id + _targetStatusID = targetStatusID - targetToot.update(liked: favoriteKind == .create, mastodonUser: mastodonUser) + targetStatus.update(liked: favoriteKind == .create, by: mastodonUser) } .tryMap { result in switch result { case .success: - guard let targetTootID = _targetTootID else { + guard let targetStatusID = _targetStatusID else { throw APIError.implicit(.badRequest) } - return targetTootID + return targetStatusID case .failure(let error): assertionFailure(error.localizedDescription) @@ -76,12 +76,12 @@ extension APIService { return nil } }() - let _oldToot: Toot? = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) + let _oldStatus: Status? = { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: mastodonAuthenticationBox.domain, id: statusID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] do { return try managedObjectContext.fetch(request).first } catch { @@ -91,15 +91,15 @@ extension APIService { }() guard let requestMastodonUser = _requestMastodonUser, - let oldToot = _oldToot else { + let oldStatus = _oldStatus else { assertionFailure() return } - APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) if favoriteKind == .destroy { - oldToot.update(favouritesCount: NSNumber(value: max(0, oldToot.favouritesCount.intValue - 1))) + oldStatus.update(favouritesCount: NSNumber(value: max(0, oldStatus.favouritesCount.intValue - 1))) } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s like status to: %{public}s. now %ld likes", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.favourited.flatMap { $0 ? "like" : "unlike" } ?? "", entity.favouritesCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in @@ -129,7 +129,7 @@ extension APIService { extension APIService { func likeList( - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, userID: String, maxID: String? = nil, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox diff --git a/Mastodon/Service/APIService/APIService+HomeTimeline.swift b/Mastodon/Service/APIService/APIService+HomeTimeline.swift index 2fbb45e0b..d4cbe69c2 100644 --- a/Mastodon/Service/APIService/APIService+HomeTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HomeTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift index cd02526d6..bd176f311 100644 --- a/Mastodon/Service/APIService/APIService+PublicTimeline.swift +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -21,7 +21,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount + limit: Int = onceRequestStatusMaxCount ) -> AnyPublisher, Error> { let query = Mastodon.API.Timeline.PublicTimelineQuery( local: nil, diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index 92ff85c10..6dc9f189b 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -16,34 +16,34 @@ extension APIService { // make local state change only func reblog( - tootObjectID: NSManagedObjectID, + statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, reblogKind: Mastodon.API.Reblog.ReblogKind - ) -> AnyPublisher { - var _targetTootID: Toot.ID? + ) -> AnyPublisher { + var _targetStatusID: Status.ID? let managedObjectContext = backgroundManagedObjectContext return managedObjectContext.performChanges { - let toot = managedObjectContext.object(with: tootObjectID) as! Toot + let status = managedObjectContext.object(with: statusObjectID) as! Status let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let targetToot = toot.reblog ?? toot - let targetTootID = targetToot.id - _targetTootID = targetTootID + let targetStatus = status.reblog ?? status + let targetStatusID = targetStatus.id + _targetStatusID = targetStatusID switch reblogKind { case .reblog: - targetToot.update(reblogged: true, mastodonUser: mastodonUser) + targetStatus.update(reblogged: true, by: mastodonUser) case .undoReblog: - targetToot.update(reblogged: false, mastodonUser: mastodonUser) + targetStatus.update(reblogged: false, by: mastodonUser) } } .tryMap { result in switch result { case .success: - guard let targetTootID = _targetTootID else { + guard let targetStatusID = _targetStatusID else { throw APIError.implicit(.badRequest) } - return targetTootID + return targetStatusID case .failure(let error): assertionFailure(error.localizedDescription) @@ -85,25 +85,25 @@ extension APIService { return } - guard let oldToot: Toot = { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: statusID) + guard let oldStatus: Status = { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: statusID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] return managedObjectContext.safeFetch(request).first }() else { return } - APIService.CoreData.merge(toot: oldToot, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) + APIService.CoreData.merge(status: oldStatus, entity: entity.reblog ?? entity, requestMastodonUser: requestMastodonUser, domain: mastodonAuthenticationBox.domain, networkDate: response.networkDate) switch reblogKind { case .undoReblog: - oldToot.update(reblogsCount: NSNumber(value: max(0, oldToot.reblogsCount.intValue - 1))) + oldStatus.update(reblogsCount: NSNumber(value: max(0, oldStatus.reblogsCount.intValue - 1))) default: break } - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update toot %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: did update status %{public}s reblog status to: %{public}s. now %ld reblog", ((#file as NSString).lastPathComponent), #line, #function, entity.id, entity.reblogged.flatMap { $0 ? "reblog" : "unreblog" } ?? "", entity.reblogsCount ) } .setFailureType(to: Error.self) .tryMap { result -> Mastodon.Response.Content in diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift new file mode 100644 index 000000000..7ad5b4745 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -0,0 +1,65 @@ +// +// APIService+Relationship.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func relationship( + domain: String, + accountIDs: [Mastodon.Entity.Account.ID], + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + fatalError() +// let authorization = authorizationBox.userAuthorization +// let requestMastodonUserID = authorizationBox.userID +// let query = Mastodon.API.Account.AccountStatuseseQuery( +// maxID: maxID, +// sinceID: sinceID, +// excludeReplies: excludeReplies, +// excludeReblogs: excludeReblogs, +// onlyMedia: onlyMedia, +// limit: limit +// ) +// +// return Mastodon.API.Account.statuses( +// session: session, +// domain: domain, +// accountID: accountID, +// query: query, +// authorization: authorization +// ) +// .flatMap { response -> AnyPublisher, Error> in +// return APIService.Persist.persistStatus( +// managedObjectContext: self.backgroundManagedObjectContext, +// domain: domain, +// query: nil, +// response: response, +// persistType: .user, +// requestMastodonUserID: requestMastodonUserID, +// log: OSLog.api +// ) +// .setFailureType(to: Error.self) +// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in +// switch result { +// case .success: +// return response +// case .failure(let error): +// throw error +// } +// } +// .eraseToAnyPublisher() +// } +// .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+UserTimeline.swift b/Mastodon/Service/APIService/APIService+UserTimeline.swift new file mode 100644 index 000000000..cb20c85ef --- /dev/null +++ b/Mastodon/Service/APIService/APIService+UserTimeline.swift @@ -0,0 +1,70 @@ +// +// APIService+UserTimeline.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func userTimeline( + domain: String, + accountID: String, + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + limit: Int = onceRequestStatusMaxCount, + excludeReplies: Bool? = nil, + excludeReblogs: Bool? = nil, + onlyMedia: Bool? = nil, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.AccountStatuseseQuery( + maxID: maxID, + sinceID: sinceID, + excludeReplies: excludeReplies, + excludeReblogs: excludeReblogs, + onlyMedia: onlyMedia, + limit: limit + ) + + return Mastodon.API.Account.statuses( + session: session, + domain: domain, + accountID: accountID, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response, + persistType: .user, + requestMastodonUserID: requestMastodonUserID, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index 655684cc5..11e6f5cac 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -44,7 +44,7 @@ final class APIService { } extension APIService { - public static let onceRequestTootMaxCount = 100 + public static let onceRequestStatusMaxCount = 100 public static let onceRequestUserMaxCount = 100 } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 512e224d2..745f47999 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -48,11 +48,11 @@ extension APIService.CoreData { if let oldMastodonUser = oldMastodonUser { // merge old mastodon usre - APIService.CoreData.mergeMastodonUser( - for: requestMastodonUser, - old: oldMastodonUser, - in: domain, + APIService.CoreData.merge( + user: oldMastodonUser, entity: entity, + requestMastodonUser: requestMastodonUser, + domain: domain, networkDate: networkDate ) return (oldMastodonUser, false) @@ -68,11 +68,15 @@ extension APIService.CoreData { } } - static func mergeMastodonUser( - for requestMastodonUser: MastodonUser?, - old user: MastodonUser, - in domain: String, +} + +extension APIService.CoreData { + + static func merge( + user: MastodonUser, entity: Mastodon.Entity.Account, + requestMastodonUser: MastodonUser?, + domain: String, networkDate: Date ) { guard networkDate > user.updatedAt else { return } @@ -84,6 +88,38 @@ extension APIService.CoreData { user.update(displayName: property.displayName) user.update(avatar: property.avatar) user.update(avatarStatic: property.avatarStatic) + user.update(header: property.header) + user.update(headerStatic: property.headerStatic) + user.update(note: property.note) + user.update(url: property.url) + user.update(statusesCount: property.statusesCount) + user.update(followingCount: property.followingCount) + user.update(followersCount: property.followersCount) + + user.didUpdate(at: networkDate) + } + +} + +extension APIService.CoreData { + + static func update( + user: MastodonUser, + entity: Mastodon.Entity.Relationship, + requestMastodonUser: MastodonUser, + domain: String, + networkDate: Date + ) { + guard networkDate > user.updatedAt else { return } + + user.update(isFollowing: entity.following, by: requestMastodonUser) + entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } + entity.endorsed.flatMap { user.update(isEndorsed: $0, by: requestMastodonUser) } + requestMastodonUser.update(isFollowing: entity.followedBy, by: user) + entity.muting.flatMap { user.update(isMuting: $0, by: requestMastodonUser) } + user.update(isBlocking: entity.blocking, by: requestMastodonUser) + entity.domainBlocking.flatMap { user.update(isDomainBlocking: $0, by: requestMastodonUser) } + entity.blockedBy.flatMap { requestMastodonUser.update(isBlocking: $0, by: user) } user.didUpdate(at: networkDate) } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index 28bfd9727..a05574b6b 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -18,39 +18,39 @@ extension APIService.CoreData { for requestMastodonUser: MastodonUser?, domain: String, entity: Mastodon.Entity.Status, - tootCache: APIService.Persist.PersistCache?, + statusCache: APIService.Persist.PersistCache?, userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog - ) -> (Toot: Toot, isTootCreated: Bool, isMastodonUserCreated: Bool) { + ) -> (status: Status, isStatusCreated: Bool, isMastodonUserCreated: Bool) { let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) } // build tree - let reblog = entity.reblog.flatMap { entity -> Toot in - let (toot, _, _) = createOrMergeStatus( + let reblog = entity.reblog.flatMap { entity -> Status in + let (status, _, _) = createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log ) - return toot + return status } - // fetch old Toot - let oldToot: Toot? = { - if let tootCache = tootCache { - return tootCache.dictionary[entity.id] + // fetch old Status + let oldStatus: Status? = { + if let statusCache = statusCache { + return statusCache.dictionary[entity.id] } else { - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: entity.id) + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: entity.id) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { @@ -62,19 +62,19 @@ extension APIService.CoreData { } }() - if let oldToot = oldToot { - // merge old Toot - APIService.CoreData.merge(toot: oldToot, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) - return (oldToot, false, false) + if let oldStatus = oldStatus { + // merge old Status + APIService.CoreData.merge(status: oldStatus, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) + return (oldStatus, false, false) } else { let (mastodonUser, isMastodonUserCreated) = createOrMergeMastodonUser(into: managedObjectContext, for: requestMastodonUser,in: domain, entity: entity.account, userCache: userCache, networkDate: networkDate, log: log) let application = entity.application.flatMap { app -> Application? in Application.insert(into: managedObjectContext, property: Application.Property(name: app.name, website: app.website, vapidKey: app.vapidKey)) } - let replyTo: Toot? = { - // could be nil if target replyTo toot's persist task in the queue + let replyTo: Status? = { + // could be nil if target replyTo status's persist task in the queue guard let inReplyToID = entity.inReplyToID, - let replyTo = tootCache?.dictionary[inReplyToID] else { return nil } + let replyTo = statusCache?.dictionary[inReplyToID] else { return nil } return replyTo }() let poll = entity.poll.flatMap { poll -> Poll in @@ -111,10 +111,10 @@ extension APIService.CoreData { guard !attachments.isEmpty else { return nil } return attachments }() - let tootProperty = Toot.Property(entity: entity, domain: domain, networkDate: networkDate) - let toot = Toot.insert( + let statusProperty = Status.Property(entity: entity, domain: domain, networkDate: networkDate) + let status = Status.insert( into: managedObjectContext, - property: tootProperty, + property: statusProperty, author: mastodonUser, reblog: reblog, application: application, @@ -130,67 +130,81 @@ extension APIService.CoreData { bookmarkedBy: (entity.bookmarked ?? false) ? requestMastodonUser : nil, pinnedBy: (entity.pinned ?? false) ? requestMastodonUser : nil ) - tootCache?.dictionary[entity.id] = toot - os_signpost(.event, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) - return (toot, true, isMastodonUserCreated) + statusCache?.dictionary[entity.id] = status + os_signpost(.event, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "did insert new tweet %{public}s: %s", mastodonUser.identifier, entity.id) + return (status, true, isMastodonUserCreated) } } +} + +extension APIService.CoreData { static func merge( - toot: Toot, + status: Status, entity: Mastodon.Entity.Status, requestMastodonUser: MastodonUser?, domain: String, networkDate: Date ) { - guard networkDate > toot.updatedAt else { return } + guard networkDate > status.updatedAt else { return } // merge poll - if let poll = toot.poll, let entity = entity.poll { + if let poll = status.poll, let entity = entity.poll { merge(poll: poll, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) } // merge metrics - if entity.favouritesCount != toot.favouritesCount.intValue { - toot.update(favouritesCount:NSNumber(value: entity.favouritesCount)) + if entity.favouritesCount != status.favouritesCount.intValue { + status.update(favouritesCount:NSNumber(value: entity.favouritesCount)) } if let repliesCount = entity.repliesCount { - if (repliesCount != toot.repliesCount?.intValue) { - toot.update(repliesCount:NSNumber(value: repliesCount)) + if (repliesCount != status.repliesCount?.intValue) { + status.update(repliesCount:NSNumber(value: repliesCount)) } } - if entity.reblogsCount != toot.reblogsCount.intValue { - toot.update(reblogsCount:NSNumber(value: entity.reblogsCount)) + if entity.reblogsCount != status.reblogsCount.intValue { + status.update(reblogsCount:NSNumber(value: entity.reblogsCount)) } // merge relationship if let mastodonUser = requestMastodonUser { if let favourited = entity.favourited { - toot.update(liked: favourited, mastodonUser: mastodonUser) + status.update(liked: favourited, by: mastodonUser) } if let reblogged = entity.reblogged { - toot.update(reblogged: reblogged, mastodonUser: mastodonUser) + status.update(reblogged: reblogged, by: mastodonUser) } if let muted = entity.muted { - toot.update(muted: muted, mastodonUser: mastodonUser) + status.update(muted: muted, by: mastodonUser) } if let bookmarked = entity.bookmarked { - toot.update(bookmarked: bookmarked, mastodonUser: mastodonUser) + status.update(bookmarked: bookmarked, by: mastodonUser) } } // set updateAt - toot.didUpdate(at: networkDate) + status.didUpdate(at: networkDate) // merge user - mergeMastodonUser(for: requestMastodonUser, old: toot.author, in: domain, entity: entity.account, networkDate: networkDate) + merge( + user: status.author, + entity: entity.account, + requestMastodonUser: requestMastodonUser, + domain: domain, + networkDate: networkDate + ) // merge indirect reblog - if let reblog = toot.reblog, let reblogEntity = entity.reblog { - merge(toot: reblog, entity: reblogEntity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: networkDate) + if let reblog = status.reblog, let reblogEntity = entity.reblog { + merge( + status: reblog, + entity: reblogEntity, + requestMastodonUser: requestMastodonUser, + domain: domain, + networkDate: networkDate + ) } } - } extension APIService.CoreData { diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift index 16461494a..eb354035f 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistCache.swift @@ -17,23 +17,23 @@ extension APIService.Persist { } -extension APIService.Persist.PersistCache where T == Toot { +extension APIService.Persist.PersistCache where T == Status { - static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + static func ids(for statuses: [Mastodon.Entity.Status]) -> Set { var value = Set() - for toot in toots { - value = value.union(ids(for: toot)) + for status in statuses { + value = value.union(ids(for: status)) } return value } - static func ids(for toot: Mastodon.Entity.Status) -> Set { + static func ids(for status: Mastodon.Entity.Status) -> Set { var value = Set() - value.insert(toot.id) - if let inReplyToID = toot.inReplyToID { + value.insert(status.id) + if let inReplyToID = status.inReplyToID { value.insert(inReplyToID) } - if let reblog = toot.reblog { + if let reblog = status.reblog { value = value.union(ids(for: reblog)) } return value @@ -43,21 +43,21 @@ extension APIService.Persist.PersistCache where T == Toot { extension APIService.Persist.PersistCache where T == MastodonUser { - static func ids(for toots: [Mastodon.Entity.Status]) -> Set { + static func ids(for statuses: [Mastodon.Entity.Status]) -> Set { var value = Set() - for toot in toots { - value = value.union(ids(for: toot)) + for status in statuses { + value = value.union(ids(for: status)) } return value } - static func ids(for toot: Mastodon.Entity.Status) -> Set { + static func ids(for status: Mastodon.Entity.Status) -> Set { var value = Set() - value.insert(toot.account.id) - if let inReplyToAccountID = toot.inReplyToAccountID { + value.insert(status.account.id) + if let inReplyToAccountID = status.inReplyToAccountID { value.insert(inReplyToAccountID) } - if let reblog = toot.reblog { + if let reblog = status.reblog { value = value.union(ids(for: reblog)) } return value diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift index b74bb7771..dab4ba6ad 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+PersistMemo.swift @@ -125,36 +125,36 @@ extension APIService.Persist.PersistMemo { } -extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { +extension APIService.Persist.PersistMemo where T == Status, U == MastodonUser { - static func createOrMergeToot( + static func createOrMergeStatus( into managedObjectContext: NSManagedObjectContext, for requestMastodonUser: MastodonUser?, requestMastodonUserID: MastodonUser.ID?, domain: String, entity: Mastodon.Entity.Status, memoType: MemoType, - tootCache: APIService.Persist.PersistCache?, + statusCache: APIService.Persist.PersistCache?, userCache: APIService.Persist.PersistCache?, networkDate: Date, log: OSLog ) -> APIService.Persist.PersistMemo { let processEntityTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "process toot %{public}s", entity.id) + os_signpost(.begin, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "process status %{public}s", entity.id) defer { - os_signpost(.end, log: log, name: "update database - process entity: createOrMergeToot", signpostID: processEntityTaskSignpostID, "finish process toot %{public}s", entity.id) + os_signpost(.end, log: log, name: "update database - process entity: createOrMergeStatus", signpostID: processEntityTaskSignpostID, "finish process status %{public}s", entity.id) } // build tree let reblogMemo = entity.reblog.flatMap { entity -> APIService.Persist.PersistMemo in - createOrMergeToot( + createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, requestMastodonUserID: requestMastodonUserID, domain: domain, entity: entity, memoType: .reblog, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log @@ -163,27 +163,27 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { let children = [reblogMemo].compactMap { $0 } - let (toot, isTootCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( + let (status, isStatusCreated, isMastodonUserCreated) = APIService.CoreData.createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, domain: domain, entity: entity, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: networkDate, log: log ) let memo = APIService.Persist.PersistMemo( - status: toot, + status: status, children: children, memoType: memoType, - statusProcessType: isTootCreated ? .create : .merge, + statusProcessType: isStatusCreated ? .create : .merge, authorProcessType: isMastodonUserCreated ? .create : .merge ) switch (memo.statusProcessType, memoType) { case (.create, .homeTimeline), (.merge, .homeTimeline): - let timelineIndex = toot.homeTimelineIndexes? + let timelineIndex = status.homeTimelineIndexes? .first { $0.userID == requestMastodonUserID } guard let requestMastodonUserID = requestMastodonUserID else { assertionFailure() @@ -192,7 +192,7 @@ extension APIService.Persist.PersistMemo where T == Toot, U == MastodonUser { if timelineIndex == nil { // make it indexed let timelineIndexProperty = HomeTimelineIndex.Property(domain: domain, userID: requestMastodonUserID) - let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, toot: toot) + let _ = HomeTimelineIndex.insert(into: managedObjectContext, property: timelineIndexProperty, status: status) } else { // enity already in home timeline } diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index 2944e66a5..dfd41094a 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -18,6 +18,7 @@ extension APIService.Persist { enum PersistTimelineType { case `public` case home + case user case likeList case lookUp } @@ -32,8 +33,8 @@ extension APIService.Persist { log: OSLog ) -> AnyPublisher, Never> { return managedObjectContext.performChanges { - let toots = response.value - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld toots…", ((#file as NSString).lastPathComponent), #line, #function, toots.count) + let statuses = response.value + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: persist %{public}ld statuses…", ((#file as NSString).lastPathComponent), #line, #function, statuses.count) let contextTaskSignpostID = OSSignpostID(log: log) let start = CACurrentMediaTime() @@ -61,18 +62,18 @@ extension APIService.Persist { // load working set into context to avoid cache miss let cacheTaskSignpostID = OSSignpostID(log: log) - os_signpost(.begin, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + os_signpost(.begin, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) // contains reblog - let tootCache: PersistCache = { - let cache = PersistCache() - let cacheIDs = PersistCache.ids(for: toots) - let cachedToots: [Toot] = { - let request = Toot.sortedFetchRequest + let statusCache: PersistCache = { + let cache = PersistCache() + let cacheIDs = PersistCache.ids(for: statuses) + let cachedStatuses: [Status] = { + let request = Status.sortedFetchRequest let ids = Array(cacheIDs) - request.predicate = Toot.predicate(domain: domain, ids: ids) + request.predicate = Status.predicate(domain: domain, ids: ids) request.returnsObjectsAsFaults = false - request.relationshipKeyPathsForPrefetching = [#keyPath(Toot.reblog)] + request.relationshipKeyPathsForPrefetching = [#keyPath(Status.reblog)] do { return try managedObjectContext.fetch(request) } catch { @@ -80,16 +81,16 @@ extension APIService.Persist { return [] } }() - for toot in cachedToots { - cache.dictionary[toot.id] = toot + for status in cachedStatuses { + cache.dictionary[status.id] = status } - os_signpost(.event, log: log, name: "load toot into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld toots", cachedToots.count) + os_signpost(.event, log: log, name: "load status into cache", signpostID: cacheTaskSignpostID, "cached %{public}ld statuses", cachedStatuses.count) return cache }() let userCache: PersistCache = { let cache = PersistCache() - let cacheIDs = PersistCache.ids(for: toots) + let cacheIDs = PersistCache.ids(for: statuses) let cachedMastodonUsers: [MastodonUser] = { let request = MastodonUser.sortedFetchRequest let ids = Array(cacheIDs) @@ -109,40 +110,41 @@ extension APIService.Persist { return cache }() - os_signpost(.end, log: log, name: "load toots & users into cache", signpostID: cacheTaskSignpostID) + os_signpost(.end, log: log, name: "load statuses & users into cache", signpostID: cacheTaskSignpostID) // remote timeline merge local timeline record set // declare it before persist - let mergedOldTootsInTimeline = tootCache.dictionary.values.filter { + let mergedOldStatusesInTimeline = statusCache.dictionary.values.filter { return $0.homeTimelineIndexes?.contains(where: { $0.userID == requestMastodonUserID }) ?? false } let updateDatabaseTaskSignpostID = OSSignpostID(log: log) - let memoType: PersistMemo.MemoType = { + let memoType: PersistMemo.MemoType = { switch persistType { case .home: return .homeTimeline case .public: return .publicTimeline + case .user: return .userTimeline case .likeList: return .likeList case .lookUp: return .lookUp } }() - var persistMemos: [PersistMemo] = [] + var persistMemos: [PersistMemo] = [] os_signpost(.begin, log: log, name: "update database", signpostID: updateDatabaseTaskSignpostID) - for entity in toots { + for entity in statuses { let processEntityTaskSignpostID = OSSignpostID(log: log) os_signpost(.begin, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) defer { os_signpost(.end, log: log, name: "update database - process entity", signpostID: processEntityTaskSignpostID, "process entity %{public}s", entity.id) } - let memo = PersistMemo.createOrMergeToot( + let memo = PersistMemo.createOrMergeStatus( into: managedObjectContext, for: requestMastodonUser, requestMastodonUserID: requestMastodonUserID, domain: domain, entity: entity, memoType: memoType, - tootCache: tootCache, + statusCache: statusCache, userCache: userCache, networkDate: response.networkDate, log: log @@ -161,19 +163,19 @@ extension APIService.Persist { } // Task 1: update anchor hasMore // update maxID anchor hasMore attribute when fetching on home timeline - // do not use working records due to anchor toot is removable on the remote - var anchorToot: Toot? + // do not use working records due to anchor status is removable on the remote + var anchorStatus: Status? if let maxID = query.maxID { do { - // load anchor toot from database - let request = Toot.sortedFetchRequest - request.predicate = Toot.predicate(domain: domain, id: maxID) + // load anchor status from database + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: maxID) request.returnsObjectsAsFaults = false request.fetchLimit = 1 - anchorToot = try managedObjectContext.fetch(request).first + anchorStatus = try managedObjectContext.fetch(request).first if persistType == .home { - let timelineIndex = anchorToot.flatMap { toot in - toot.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) + let timelineIndex = anchorStatus.flatMap { status in + status.homeTimelineIndexes?.first(where: { $0.userID == requestMastodonUserID }) } timelineIndex?.update(hasMore: false) } else { @@ -184,16 +186,16 @@ extension APIService.Persist { } } - // Task 2: set last toot hasMore when fetched toots not overlap with the timeline in the local database + // Task 2: set last status hasMore when fetched statuses not overlap with the timeline in the local database let _oldestMemo = persistMemos .sorted(by: { $0.status.createdAt < $1.status.createdAt }) .first if let oldestMemo = _oldestMemo { - if let anchorToot = anchorToot { + if let anchorStatus = anchorStatus { // using anchor. set hasMore when (overlap itself OR no overlap) AND oldest record NOT anchor - let isNoOverlap = mergedOldTootsInTimeline.isEmpty - let isOnlyOverlapItself = mergedOldTootsInTimeline.count == 1 && mergedOldTootsInTimeline.first?.id == anchorToot.id - let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorToot.id + let isNoOverlap = mergedOldStatusesInTimeline.isEmpty + let isOnlyOverlapItself = mergedOldStatusesInTimeline.count == 1 && mergedOldStatusesInTimeline.first?.id == anchorStatus.id + let isAnchorEqualOldestRecord = oldestMemo.status.id == anchorStatus.id if (isNoOverlap || isOnlyOverlapItself) && !isAnchorEqualOldestRecord { if persistType == .home { let timelineIndex = oldestMemo.status.homeTimelineIndexes? @@ -204,7 +206,7 @@ extension APIService.Persist { } } - } else if mergedOldTootsInTimeline.isEmpty { + } else if mergedOldStatusesInTimeline.isEmpty { // no anchor. set hasMore when no overlap if persistType == .home { let timelineIndex = oldestMemo.status.homeTimelineIndexes? @@ -232,7 +234,7 @@ extension APIService.Persist { let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in return next.statusProcessType == .create ? result + 1 : result }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldTootsInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) } #endif diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift index 5d0191ff3..e1337204b 100644 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -58,10 +58,10 @@ extension StatusPrefetchingService { guard let self = self else { return } let backgroundManagedObjectContext = apiService.backgroundManagedObjectContext backgroundManagedObjectContext.performChanges { - guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Toot else { return } + guard let status = backgroundManagedObjectContext.object(with: statusObjectID) as? Status else { return } do { - let predicate = Toot.predicate(domain: domain, id: replyToStatusID) - let request = Toot.sortedFetchRequest + let predicate = Status.predicate(domain: domain, id: replyToStatusID) + let request = Status.sortedFetchRequest request.predicate = predicate request.returnsObjectsAsFaults = false request.fetchLimit = 1 diff --git a/Mastodon/Service/ViedeoPlaybackService.swift b/Mastodon/Service/ViedeoPlaybackService.swift index 15348c6e9..523af3103 100644 --- a/Mastodon/Service/ViedeoPlaybackService.swift +++ b/Mastodon/Service/ViedeoPlaybackService.swift @@ -101,8 +101,8 @@ extension VideoPlaybackService { } extension VideoPlaybackService { - func markTransitioning(for toot: Toot) { - guard let videoAttachment = toot.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } + func markTransitioning(for status: Status) { + guard let videoAttachment = status.mediaAttachments?.filter({ $0.type == .gifv || $0.type == .video }).first else { return } guard let videoPlayerViewModel = dequeueVideoPlayerViewModel(for: videoAttachment) else { return } videoPlayerViewModel.isTransitioning = true } diff --git a/Mastodon/ViewController.swift b/Mastodon/ViewController.swift new file mode 100644 index 000000000..9be4589cc --- /dev/null +++ b/Mastodon/ViewController.swift @@ -0,0 +1,15 @@ +// +// ViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-31. +// + +import UIKit + +class ViewController: UIViewController { + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .darkContent + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift new file mode 100644 index 000000000..ec8bf9d5e --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -0,0 +1,69 @@ +// +// Mastodon+API+Account+Friendship.swift +// +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine + +extension Mastodon.API.Account { + + static func accountsRelationshipsEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/relationships") + } + + /// Check relationships to other accounts + /// + /// Find out whether a given account is followed, blocked, muted, etc. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/#perform-actions-on-an-account/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `RelationshipQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `[Relationship]` nested in the response + public static func relationships( + session: URLSession, + domain: String, + query: RelationshipQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsRelationshipsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Relationship].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RelationshipQuery: GetQuery { + public let ids: [Mastodon.Entity.Account.ID] + + public init(ids: [Mastodon.Entity.Account.ID]) { + self.ids = ids + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + for id in ids { + items.append(URLQueryItem(name: "id[]", value: id)) + } + guard !items.isEmpty else { return nil } + return items + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 3bb81dc6b..0f98dbe05 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -53,3 +53,82 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + static func accountStatusesEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/\(accountID)/statuses") + } + + /// View statuses from followed users. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/30 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountStatuseseQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func statuses( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: AccountStatuseseQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountStatusesEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct AccountStatuseseQuery: GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let excludeReplies: Bool? // undocumented + public let excludeReblogs: Bool? + public let onlyMedia: Bool? + public let limit: Int? + + public init( + maxID: Mastodon.Entity.Status.ID?, + sinceID: Mastodon.Entity.Status.ID?, + excludeReplies: Bool?, + excludeReblogs: Bool?, + onlyMedia: Bool?, + limit: Int? + ) { + self.maxID = maxID + self.sinceID = sinceID + self.excludeReplies = excludeReplies + self.excludeReblogs = excludeReblogs + self.onlyMedia = onlyMedia + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + excludeReplies.flatMap { items.append(URLQueryItem(name: "exclude_replies", value: $0.queryItemValue)) } + excludeReblogs.flatMap { items.append(URLQueryItem(name: "exclude_reblogs", value: $0.queryItemValue)) } + onlyMedia.flatMap { items.append(URLQueryItem(name: "only_media", value: $0.queryItemValue)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } + +} From 058457605569cbdbb9b11a31f74143246646a6ee Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 30 Mar 2021 14:31:52 +0800 Subject: [PATCH 162/400] fix: Update server rules scene UI design --- Localization/app.json | 2 + Mastodon.xcodeproj/project.pbxproj | 16 ++++ Mastodon/Coordinator/SceneCoordinator.swift | 5 ++ Mastodon/Generated/Strings.swift | 4 + .../Resources/en.lproj/Localizable.strings | 2 + .../MastodonServerRulesViewController.swift | 84 +++++++++++++------ .../MastodonServerRulesViewModel.swift | 15 ++-- .../Scene/Webview/WebViewController.swift | 67 +++++++++++++++ Mastodon/Scene/Webview/WebViewModel.swift | 17 ++++ .../MastodonSDK/API/Mastodon+API.swift | 7 ++ 10 files changed, 186 insertions(+), 33 deletions(-) create mode 100644 Mastodon/Scene/Webview/WebViewController.swift create mode 100644 Mastodon/Scene/Webview/WebViewModel.swift diff --git a/Localization/app.json b/Localization/app.json index e2d64db0c..94b785f2c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -162,6 +162,8 @@ "title": "Some ground rules.", "subtitle": "These rules are set by the admins of %s.", "prompt": "By continuing, you're subject to the terms of service and privacy policy for %s.", + "terms_of_service": "terms of service", + "privacy_policy": "privacy policy", "button": { "confirm": "I Agree" } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 5179c3870..773c869b1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -101,6 +101,8 @@ 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; }; + 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; + 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; @@ -410,6 +412,8 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; + 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; @@ -951,6 +955,15 @@ name = Frameworks; sourceTree = ""; }; + 5D03938E2612D200007FE196 /* Webview */ = { + isa = PBXGroup; + children = ( + 5D03938F2612D259007FE196 /* WebViewController.swift */, + 5D0393952612D266007FE196 /* WebViewModel.swift */, + ); + path = Webview; + sourceTree = ""; + }; DB01409B25C40BB600F9F3CF /* Onboarding */ = { isa = PBXGroup; children = ( @@ -1328,6 +1341,7 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( + 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, @@ -1860,6 +1874,7 @@ DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, + 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -1939,6 +1954,7 @@ 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, + 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ed0b18f8..5590025db 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -46,6 +46,7 @@ extension SceneCoordinator { case mastodonServerRules(viewModel: MastodonServerRulesViewModel) case mastodonConfirmEmail(viewModel: MastodonConfirmEmailViewModel) case mastodonResendEmail(viewModel: MastodonResendEmailViewModel) + case mastodonWebView(viewModel:WebViewModel) // compose case compose(viewModel: ComposeViewModel) @@ -193,6 +194,10 @@ private extension SceneCoordinator { let _viewController = MastodonResendEmailViewController() _viewController.viewModel = viewModel viewController = _viewController + case .mastodonWebView(let viewModel): + let _viewController = WebViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .compose(let viewModel): let _viewController = ComposeViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1d2bd87a7..1294c78e2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -410,6 +410,8 @@ internal enum L10n { } } internal enum ServerRules { + /// privacy policy + internal static let privacyPolicy = L10n.tr("Localizable", "Scene.ServerRules.PrivacyPolicy") /// By continuing, you're subject to the terms of service and privacy policy for %@. internal static func prompt(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.ServerRules.Prompt", String(describing: p1)) @@ -418,6 +420,8 @@ internal enum L10n { internal static func subtitle(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.ServerRules.Subtitle", String(describing: p1)) } + /// terms of service + internal static let termsOfService = L10n.tr("Localizable", "Scene.ServerRules.TermsOfService") /// Some ground rules. internal static let title = L10n.tr("Localizable", "Scene.ServerRules.Title") internal enum Button { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index aecd96757..e229d0f48 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -129,8 +129,10 @@ tap the link to confirm your account."; "Scene.ServerPicker.Title" = "Pick a Server, any server."; "Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; "Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; "Scene.Welcome.Slogan" = "Social networking back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 467239b87..86b9dc3eb 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -8,6 +8,8 @@ import os.log import UIKit import Combine +import MastodonSDK +import SafariServices final class MastodonServerRulesViewController: UIViewController, NeedsDependency { @@ -44,19 +46,20 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency return label }() - let bottonContainerView: UIView = { + let bottomContainerView: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.Background.onboardingBackground.color return view }() - private(set) lazy var bottomPromptLabel: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) - label.textColor = .label - label.text = L10n.Scene.ServerRules.prompt(viewModel.domain) - label.numberOfLines = 0 - return label + private(set) lazy var bottomPromptTextView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.textColor = .label + textView.isSelectable = true + textView.isEditable = false + textView.backgroundColor = Asset.Colors.Background.onboardingBackground.color + return textView }() let confirmButton: PrimaryActionButton = { @@ -86,36 +89,39 @@ extension MastodonServerRulesViewController { super.viewDidLoad() setupOnboardingAppearance() + configTextView() + defer { setupNavigationBarBackgroundView() } - bottonContainerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(bottonContainerView) + bottomContainerView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(bottomContainerView) NSLayoutConstraint.activate([ - view.bottomAnchor.constraint(equalTo: bottonContainerView.bottomAnchor), - bottonContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - bottonContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: bottomContainerView.bottomAnchor), + bottomContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - bottonContainerView.preservesSuperviewLayoutMargins = true + bottomContainerView.preservesSuperviewLayoutMargins = true defer { - view.bringSubviewToFront(bottonContainerView) + view.bringSubviewToFront(bottomContainerView) } confirmButton.translatesAutoresizingMaskIntoConstraints = false - bottonContainerView.addSubview(confirmButton) + bottomContainerView.addSubview(confirmButton) NSLayoutConstraint.activate([ - bottonContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), - confirmButton.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), - bottonContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), + bottomContainerView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: confirmButton.bottomAnchor, constant: MastodonServerRulesViewController.viewBottomPaddingHeight), + confirmButton.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), + bottomContainerView.readableContentGuide.trailingAnchor.constraint(equalTo: confirmButton.trailingAnchor, constant: MastodonServerRulesViewController.actionButtonMargin), confirmButton.heightAnchor.constraint(equalToConstant: MastodonServerRulesViewController.actionButtonHeight).priority(.defaultHigh), ]) - bottomPromptLabel.translatesAutoresizingMaskIntoConstraints = false - bottonContainerView.addSubview(bottomPromptLabel) + bottomPromptTextView.translatesAutoresizingMaskIntoConstraints = false + bottomContainerView.addSubview(bottomPromptTextView) NSLayoutConstraint.activate([ - bottomPromptLabel.topAnchor.constraint(equalTo: bottonContainerView.topAnchor, constant: 20), - bottomPromptLabel.leadingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.leadingAnchor), - bottomPromptLabel.trailingAnchor.constraint(equalTo: bottonContainerView.readableContentGuide.trailingAnchor), - confirmButton.topAnchor.constraint(equalTo: bottomPromptLabel.bottomAnchor, constant: 20), + bottomPromptTextView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20), + bottomPromptTextView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor), + bottomPromptTextView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.trailingAnchor), + bottomPromptTextView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 50), + confirmButton.topAnchor.constraint(equalTo: bottomPromptTextView.frameLayoutGuide.bottomAnchor, constant: 20), ]) scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -165,8 +171,32 @@ extension MastodonServerRulesViewController { extension MastodonServerRulesViewController { func updateScrollViewContentInset() { view.layoutIfNeeded() - scrollView.contentInset.bottom = bottonContainerView.frame.height - scrollView.verticalScrollIndicatorInsets.bottom = bottonContainerView.frame.height + scrollView.contentInset.bottom = bottomContainerView.frame.height + scrollView.verticalScrollIndicatorInsets.bottom = bottomContainerView.frame.height + } + + func configTextView() { + let linkColor = Asset.Colors.Button.normal.color + + let str = NSString(string: L10n.Scene.ServerRules.prompt(viewModel.domain)) + let termsOfServiceRange = str.range(of: L10n.Scene.ServerRules.termsOfService) + let privacyRange = str.range(of: L10n.Scene.ServerRules.privacyPolicy) + let attributeString = NSMutableAttributedString(string: L10n.Scene.ServerRules.prompt(viewModel.domain), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body), NSAttributedString.Key.foregroundColor: UIColor.label]) + attributeString.addAttribute(.link, value: Mastodon.API.serverRulesURL(domain: viewModel.domain), range: termsOfServiceRange) + attributeString.addAttribute(.link, value: Mastodon.API.privacyURL(domain: viewModel.domain), range: privacyRange) + let linkAttributes = [NSAttributedString.Key.foregroundColor:linkColor] + bottomPromptTextView.attributedText = attributeString + bottomPromptTextView.linkTextAttributes = linkAttributes + bottomPromptTextView.delegate = self + } + +} + +extension MastodonServerRulesViewController: UITextViewDelegate { + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { + let safariVC = SFSafariViewController(url: URL) + self.present(safariVC, animated: true, completion: nil) + return false } } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 89f31bbc2..14b4f0941 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -35,13 +35,16 @@ final class MastodonServerRulesViewModel { var rulesAttributedString: NSAttributedString { let attributedString = NSMutableAttributedString(string: "\n") + let configuration = UIImage.SymbolConfiguration(font: .preferredFont(forTextStyle: .title3)) for (i, rule) in rules.enumerated() { - let index = String(i + 1) - let indexString = NSAttributedString(string: index + ". ", attributes: [ - NSAttributedString.Key.foregroundColor: UIColor.secondaryLabel - ]) - let ruleString = NSAttributedString(string: rule.text + "\n\n") - attributedString.append(indexString) + let imageName = String(i + 1) + ".circle.fill" + let image = UIImage(systemName: imageName, withConfiguration: configuration)! + let attachment = NSTextAttachment() + attachment.image = image.withTintColor(.black) + let imageAttribute = NSAttributedString(attachment: attachment) + + let ruleString = NSAttributedString(string: " " + rule.text + "\n\n") + attributedString.append(imageAttribute) attributedString.append(ruleString) } return attributedString diff --git a/Mastodon/Scene/Webview/WebViewController.swift b/Mastodon/Scene/Webview/WebViewController.swift new file mode 100644 index 000000000..bde6e8936 --- /dev/null +++ b/Mastodon/Scene/Webview/WebViewController.swift @@ -0,0 +1,67 @@ +// +// WebViewController.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/30. +// + +import Foundation +import Combine +import os.log +import UIKit +import WebKit + +final class WebViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: WebViewModel! + + let webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.processPool = WKProcessPool() + let webView = WKWebView(frame: .zero, configuration: configuration) + return webView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + + // cleanup cookie + let httpCookieStore = webView.configuration.websiteDataStore.httpCookieStore + httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + httpCookieStore.delete(cookie, completionHandler: nil) + } + } + } + +} + +extension WebViewController { + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.cancelBarButtonItemPressed(_:))) + + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let request = URLRequest(url: viewModel.url) + webView.load(request) + } +} + +extension WebViewController { + @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { + dismiss(animated: true, completion: nil) + } +} diff --git a/Mastodon/Scene/Webview/WebViewModel.swift b/Mastodon/Scene/Webview/WebViewModel.swift new file mode 100644 index 000000000..4e6483c98 --- /dev/null +++ b/Mastodon/Scene/Webview/WebViewModel.swift @@ -0,0 +1,17 @@ +// +// WebViewModel.swift +// Mastodon +// +// Created by xiaojian sun on 2021/3/30. +// + +import Foundation + +final class WebViewModel { + public init(url: URL) { + self.url = url + } + + // input + let url: URL +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index ac960e710..376dbeb36 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -89,6 +89,13 @@ extension Mastodon.API { return URL(string: "https://" + domain + "/auth/confirmation/new")! } + public static func serverRulesURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/about/more")! + } + + public static func privacyURL(domain: String) -> URL { + return URL(string: "https://" + domain + "/terms")! + } } extension Mastodon.API { From efbb32648cab53e199daaded4a821df45ead3090 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 1 Apr 2021 14:54:15 +0800 Subject: [PATCH 163/400] chore: update naming for StatusFetchedResultsController --- .../StatusFetchedResultsController.swift | 4 ++-- Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index 704f2ab78..a61429ab8 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -23,7 +23,7 @@ final class StatusFetchedResultsController: NSObject { let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) // output - let items = CurrentValueSubject<[NSManagedObjectID], Never>([]) + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { self.domain.value = domain ?? "" @@ -81,6 +81,6 @@ extension StatusFetchedResultsController: NSFetchedResultsControllerDelegate { } .sorted { $0.0 < $1.0 } .map { $0.1.objectID } - self.items.value = items + self.objectIDs.value = items } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index de17376d2..a550dc829 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -60,7 +60,7 @@ class UserTimelineViewModel: NSObject { .store(in: &disposeBag) - statusFetchedResultsController.items + statusFetchedResultsController.objectIDs .receive(on: DispatchQueue.main) .sink { [weak self] objectIDs in guard let self = self else { return } From b63a5ebe5faba3520375873469adf510333946ae Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 10:21:51 +0800 Subject: [PATCH 164/400] feat: use search api to fetch tag info --- Localization/app.json | 3 + Mastodon.xcodeproj/project.pbxproj | 12 ++ Mastodon/Generated/Strings.swift | 6 + .../Resources/en.lproj/Localizable.strings | 1 + .../HashtagTimelineViewController.swift | 28 +++- .../HashtagTimelineViewModel.swift | 19 +++ .../View/HashtagTimelineTitleView.swift | 59 +++++++++ .../API/Mastodon+API+Favorites.swift | 2 +- .../API/Mastodon+API+Notifications.swift | 125 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API+Search.swift | 19 ++- .../API/Mastodon+API+Timeline.swift | 4 +- .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+SearchResult.swift | 6 +- 13 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift diff --git a/Localization/app.json b/Localization/app.json index e2d64db0c..289f91277 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -240,6 +240,9 @@ "placeholder": "Search hashtags and users", "cancel": "Cancel" } + }, + "hashtag": { + "prompt": "%s people talking" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b56a389d9..9dc5cfb1d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -325,6 +326,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -652,9 +654,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0F1E2D102615C39800C38565 /* View */ = { + isa = PBXGroup; + children = ( + 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */, + ); + path = View; + sourceTree = ""; + }; 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { isa = PBXGroup; children = ( + 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, @@ -1907,6 +1918,7 @@ 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1d2bd87a7..1afa816ff 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -249,6 +249,12 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") } } + internal enum Hashtag { + /// %@ people talking + internal static func prompt(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Hashtag.Prompt", String(describing: p1)) + } + } internal enum HomeTimeline { /// Home internal static let title = L10n.tr("Localizable", "Scene.HomeTimeline.Title") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index aecd96757..2dfa0ebc7 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -80,6 +80,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ba6d30e32..fd33cb883 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -95,13 +95,21 @@ extension HashtagTimelineViewController { } } .store(in: &disposeBag) + + viewModel.hashtagEntity + .receive(on: DispatchQueue.main) + .sink { [weak self] tag in + self?.updatePromptTitle() + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + viewModel.fetchTag() guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } - tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + refreshControl.beginRefreshing() refreshControl.sendActions(for: .valueChanged) } @@ -123,6 +131,24 @@ extension HashtagTimelineViewController { self.tableView.reloadData() } } + + private func updatePromptTitle() { + guard let histories = viewModel.hashtagEntity.value?.history else { + navigationItem.prompt = nil + return + } + if histories.isEmpty { + // No tag history, remove the prompt title + navigationItem.prompt = nil + } else { + let sortedHistory = histories.sorted { (h1, h2) -> Bool in + return h1.day > h2.day + } + if let accountsNumber = sortedHistory.first?.accounts { + navigationItem.prompt = L10n.Scene.Hashtag.prompt(accountsNumber) + } + } + } } extension HashtagTimelineViewController { diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 19144d199..8f2e07874 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -27,6 +27,7 @@ final class HashtagTimelineViewModel: NSObject { let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) + let hashtagEntity = CurrentValueSubject(nil) weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? weak var tableView: UITableView? @@ -105,6 +106,24 @@ final class HashtagTimelineViewModel: NSObject { .store(in: &disposeBag) } + func fetchTag() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags) + context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { _ in + + } receiveValue: { [weak self] response in + let matchedTag = response.value.hashtags.first { tag -> Bool in + return tag.name == self?.hashTag + } + self?.hashtagEntity.send(matchedTag) + } + .store(in: &disposeBag) + + } + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift new file mode 100644 index 000000000..04782bf63 --- /dev/null +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -0,0 +1,59 @@ +// +// HashtagTimelineTitleView.swift +// Mastodon +// +// Created by BradGao on 2021/4/1. +// + +import UIKit + +final class HashtagTimelineTitleView: UIView { + + let containerView = UIStackView() + + let imageView = UIImageView() + let button = RoundedEdgesButton() + let label = UILabel() + + // input + private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? + weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? + + // output + private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension HomeTimelineNavigationBarTitleView { + private func _init() { + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + containerView.addArrangedSubview(imageView) + button.translatesAutoresizingMaskIntoConstraints = false + containerView.addArrangedSubview(button) + NSLayoutConstraint.activate([ + button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) + ]) + containerView.addArrangedSubview(label) + + configure(state: .logoImage) + button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 3b01c2c13..cb01e83eb 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -121,7 +121,7 @@ extension Mastodon.API.Favorites { case destroy } - public struct ListQuery: GetQuery,TimelineQueryType { + public struct ListQuery: GetQuery,PagedQueryType { public var limit: Int? public var minID: String? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift new file mode 100644 index 000000000..cdee82926 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -0,0 +1,125 @@ +// +// File.swift +// +// +// Created by BradGao on 2021/4/1. +// + +import Foundation +import Combine + +extension Mastodon.API.Notifications { + static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") + } + static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) + } + + /// Get all notifications + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `GetAllNotificationsQuery` with query parameters + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func getAll( + session: URLSession, + domain: String, + query: GetAllNotificationsQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: notificationsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Notification].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Get a single notification + /// + /// - Since: 0.0.0 + /// - Version: 3.1.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - notificationID: ID of the notification. + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Token` nested in the response + public static func get( + session: URLSession, + domain: String, + notificationID: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Notification.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + public let limit: Int? + public let excludeTypes: [String]? + public let accountID: String? + + public init( + maxID: Mastodon.Entity.Status.ID? = nil, + sinceID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + limit: Int? = nil, + excludeTypes: [String]? = nil, + accountID: String? = nil + ) { + self.maxID = maxID + self.sinceID = sinceID + self.minID = minID + self.limit = limit + self.excludeTypes = excludeTypes + self.accountID = accountID + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + if let excludeTypes = excludeTypes { + excludeTypes.forEach { + items.append(URLQueryItem(name: "exclude_types[]", value: $0)) + } + } + accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } + guard !items.isEmpty else { return nil } + return items + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 8f266437f..42dfc1e25 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -50,8 +50,21 @@ extension Mastodon.API.Search { } extension Mastodon.API.Search { + public enum SearchType: String, Codable { + case ccounts, hashtags, statuses + } + public struct Query: Codable, GetQuery { - public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { + public init(q: String, + type: SearchType? = nil, + accountID: Mastodon.Entity.Account.ID? = nil, + maxID: Mastodon.Entity.Status.ID? = nil, + minID: Mastodon.Entity.Status.ID? = nil, + excludeUnreviewed: Bool? = nil, + resolve: Bool? = nil, + limit: Int? = nil, + offset: Int? = nil, + following: Bool? = nil) { self.accountID = accountID self.maxID = maxID self.minID = minID @@ -67,7 +80,7 @@ extension Mastodon.API.Search { public let accountID: Mastodon.Entity.Account.ID? public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? - public let type: String? + public let type: SearchType? public let excludeUnreviewed: Bool? // Filter out unreviewed tags? Defaults to false. Use true when trying to find trending tags. public let q: String public let resolve: Bool? // Attempt WebFinger lookup. Defaults to false. @@ -80,7 +93,7 @@ extension Mastodon.API.Search { accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } minID.flatMap { items.append(URLQueryItem(name: "min_id", value: $0)) } - type.flatMap { items.append(URLQueryItem(name: "type", value: $0)) } + type.flatMap { items.append(URLQueryItem(name: "type", value: $0.rawValue)) } excludeUnreviewed.flatMap { items.append(URLQueryItem(name: "exclude_unreviewed", value: $0.queryItemValue)) } items.append(URLQueryItem(name: "q", value: q)) resolve.flatMap { items.append(URLQueryItem(name: "resolve", value: $0.queryItemValue)) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index a9b5c4f12..c1857ae82 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -121,14 +121,14 @@ extension Mastodon.API.Timeline { } } -public protocol TimelineQueryType { +public protocol PagedQueryType { var maxID: Mastodon.Entity.Status.ID? { get } var sinceID: Mastodon.Entity.Status.ID? { get } } extension Mastodon.API.Timeline { - public typealias TimelineQuery = TimelineQueryType + public typealias TimelineQuery = PagedQueryType public struct PublicTimelineQuery: Codable, TimelineQuery, GetQuery { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index ac960e710..cdb6c2f14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -107,6 +107,7 @@ extension Mastodon.API { public enum Search { } public enum Trends { } public enum Suggestions { } + public enum Notifications { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f06f1a54e..f10339664 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,9 +8,9 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { - let accounts: [Mastodon.Entity.Account] - let statuses: [Mastodon.Entity.Status] - let hashtags: [Mastodon.Entity.Tag] + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] } } From 458ab6bcdaf1bf4bfd381d60d4cbbb9ad348fbe6 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 1 Apr 2021 20:54:57 +0800 Subject: [PATCH 165/400] feature: search recommend page --- Localization/app.json | 13 ++ Mastodon.xcodeproj/project.pbxproj | 24 +++ .../Section/RecomendHashTagSection.swift | 26 +++ .../Section/RecommendAccountSection.swift | 26 +++ Mastodon/Extension/UIView+Constraint.swift | 88 +++++----- Mastodon/Generated/Assets.swift | 1 + Mastodon/Generated/Strings.swift | 22 +++ .../Background/search.colorset/Contents.json | 20 +++ .../Resources/en.lproj/Localizable.strings | 7 + ...hRecommendAccountsCollectionViewCell.swift | 152 ++++++++++++++++++ ...earchRecommendTagsCollectionViewCell.swift | 54 ++++++- .../SearchViewController+recomendView.swift | 124 +++++++++++--- .../Scene/Search/SearchViewController.swift | 80 ++++++++- Mastodon/Scene/Search/SearchViewModel.swift | 73 ++++++--- .../SearchRecommendCollectionHeader.swift | 89 ++++++++++ .../Entity/Mastodon+Entity+Account.swift | 12 +- .../Entity/Mastodon+Entity+History.swift | 2 +- .../Entity/Mastodon+Entity+Tag.swift | 6 +- 18 files changed, 710 insertions(+), 109 deletions(-) create mode 100644 Mastodon/Diffiable/Section/RecomendHashTagSection.swift create mode 100644 Mastodon/Diffiable/Section/RecommendAccountSection.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json create mode 100644 Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift create mode 100644 Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift diff --git a/Localization/app.json b/Localization/app.json index 94b785f2c..433515fef 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -241,6 +241,19 @@ "searchBar": { "placeholder": "Search hashtags and users", "cancel": "Cancel" + }, + "recommend": { + "buttonText": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "Except for Sam, you will not like his account.", + "follow": "Follow" + } } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 773c869b1..19228f2e7 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -94,6 +94,10 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */; }; + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */; }; 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */; }; @@ -401,6 +405,10 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = ""; }; + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; 2DF75B9A25D0E27500694EC8 /* StatusProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusProviderFacade.swift; sourceTree = ""; }; 2DF75BA025D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; @@ -718,6 +726,7 @@ isa = PBXGroup; children = ( 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */, + 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */, ); path = CollectionViewCell; sourceTree = ""; @@ -871,6 +880,8 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, + 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, ); @@ -936,6 +947,14 @@ path = Decoration; sourceTree = ""; }; + 2DE0FAC62615F5D200CDF649 /* View */ = { + isa = PBXGroup; + children = ( + 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; 2DF75BB725D1473400694EC8 /* Stack */ = { isa = PBXGroup; children = ( @@ -1418,6 +1437,7 @@ DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( + 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, @@ -1896,6 +1916,7 @@ DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, + 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, @@ -1911,6 +1932,7 @@ 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, DB4481C625EE2ADA00BEFB67 /* PollSection.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, + 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, @@ -1939,6 +1961,7 @@ DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, @@ -1983,6 +2006,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, + 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift new file mode 100644 index 000000000..2f78e73b9 --- /dev/null +++ b/Mastodon/Diffiable/Section/RecomendHashTagSection.swift @@ -0,0 +1,26 @@ +// +// RecomendHashTagSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecomendHashTagSection: Equatable, Hashable { + case main +} + +extension RecomendHashTagSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell + cell.config(with: tag) + return cell + } + } +} diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift new file mode 100644 index 000000000..b08c9abab --- /dev/null +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -0,0 +1,26 @@ +// +// RecommendAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import MastodonSDK +import UIKit + +enum RecommendAccountSection: Equatable, Hashable { + case main +} + +extension RecommendAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, account -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell + cell.config(with: account) + return cell + } + } +} diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift index 42d3bfd93..baa923ada 100644 --- a/Mastodon/Extension/UIView+Constraint.swift +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -42,17 +42,17 @@ extension UIView { attribute: .top, multiplier: 1.0, constant: toSuperviewEdges?.top ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .trailing, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .trailing, multiplier: 1.0, constant: toSuperviewEdges?.right ?? 0.0), - NSLayoutConstraint(item: self, + NSLayoutConstraint(item: view, attribute: .bottom, relatedBy: .equal, - toItem: view, + toItem: self, attribute: .bottom, multiplier: 1.0, constant: toSuperviewEdges?.bottom ?? 0.0) @@ -89,40 +89,6 @@ extension UIView { constant: constant) } - func constraint(toBottom: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: toBottom, attribute: .bottom, multiplier: 1.0, constant: constant) - } - - func pinToBottom(to: UIView, height: CGFloat) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: to), - constraint(toBottom: to, constant: 0.0), - constraint(.height, constant: height) - ]) - } - - func constraint(toTop: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .bottom, relatedBy: .equal, toItem: toTop, attribute: .top, multiplier: 1.0, constant: constant) - } - - func constraint(toTrailing: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .leading, relatedBy: .equal, toItem: toTrailing, attribute: .trailing, multiplier: 1.0, constant: constant) - } - - func constraint(toLeading: UIView, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: .trailing, relatedBy: .equal, toItem: toLeading, attribute: .leading, multiplier: 1.0, constant: constant) - } - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -204,17 +170,6 @@ extension UIView { ]) } - func pinTo(viewAbove: UIView, padding: CGFloat = 0.0, height: CGFloat? = nil) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.width, toView: viewAbove), - constraint(toBottom: viewAbove, constant: padding), - self.centerXAnchor.constraint(equalTo: viewAbove.centerXAnchor), - height != nil ? constraint(.height, constant: height!) : nil - ]) - } - func pin(toSize: CGSize) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -223,6 +178,25 @@ extension UIView { heightAnchor.constraint(equalToConstant: toSize.height)]) } + func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + var constraints = [NSLayoutConstraint]() + if let topConstant = top { + constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant)) + } + if let leftConstant = left { + constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant)) + } + if let bottomConstant = bottom { + constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant)) + } + if let rightConstant = right { + constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant)) + } + constrain(constraints) + + } func pinTopLeft(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -231,6 +205,14 @@ extension UIView { topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + func pinTopLeft(top: CGFloat, left: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } + func pinTopRight(padding: CGFloat) { guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false @@ -238,6 +220,14 @@ extension UIView { view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) } + + func pinTopRight(top: CGFloat, right: CGFloat) { + guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } + translatesAutoresizingMaskIntoConstraints = false + constrain([ + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right), + topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) + } func pinTopLeft(toView: UIView, topPadding: CGFloat) { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 82e7f8b1f..cbb5c2940 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -42,6 +42,7 @@ internal enum Asset { internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let search = ColorAsset(name: "Colors/Background/search") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let success = ColorAsset(name: "Colors/Background/success") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 1294c78e2..98f08ef06 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -370,6 +370,28 @@ internal enum L10n { } } internal enum Search { + internal enum Recommend { + /// See All + internal static let buttontext = L10n.tr("Localizable", "Scene.Search.Recommend.Buttontext") + internal enum Accounts { + /// Except for Sam, you will not like his account. + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") + /// Follow + internal static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") + /// Accounts you might like + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Title") + } + internal enum HashTag { + /// Hashtags that are getting quite a bit of attention among people you follow + internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Description") + /// %@ people are talking + internal static func peopleTalking(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.PeopleTalking", String(describing: p1)) + } + /// Trending in your timeline + internal static let title = L10n.tr("Localizable", "Scene.Search.Recommend.HashTag.Title") + } + } internal enum Searchbar { /// Cancel internal static let cancel = L10n.tr("Localizable", "Scene.Search.Searchbar.Cancel") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json new file mode 100644 index 000000000..838e44e44 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "232", + "green" : "225", + "red" : "217" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e229d0f48..db808b56d 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -115,6 +115,13 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.Buttontext" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; "Scene.Search.Searchbar.Cancel" = "Cancel"; "Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; "Scene.ServerPicker.Button.Category.All" = "All"; diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift new file mode 100644 index 000000000..bed61f228 --- /dev/null +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -0,0 +1,152 @@ +// +// SearchRecommendAccountsCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit +import MastodonSDK + +class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { + let avatarImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let headerImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 8 + imageView.clipsToBounds = true + return imageView + }() + + let displayNameLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .systemFont(ofSize: 18, weight: .semibold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let acctLabel: UILabel = { + let label = UILabel() + label.textColor = .white + label.font = .preferredFont(forTextStyle: .body) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let followButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(.white, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) + button.layer.cornerRadius = 12 + button.layer.borderWidth = 3 + button.layer.borderColor = UIColor.white.cgColor + return button + }() + + override func prepareForReuse() { + super.prepareForReuse() + headerImageView.af.cancelImageRequest() + avatarImageView.af.cancelImageRequest() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendAccountsCollectionViewCell { + private func configure() { + headerImageView.backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + + contentView.addSubview(headerImageView) + headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0 ) + + contentView.addSubview(avatarImageView) + avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) + avatarImageView.constrain([ + avatarImageView.constraint(.top, toView: contentView), + avatarImageView.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(displayNameLabel) + displayNameLabel.constrain([ + displayNameLabel.constraint(.top, toView: contentView,constant: 108), + displayNameLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(acctLabel) + acctLabel.constrain([ + acctLabel.constraint(.top, toView: contentView,constant: 132), + acctLabel.constraint(.centerX, toView: contentView) + ]) + + contentView.addSubview(followButton) + followButton.pin(toSize: CGSize(width: 76, height: 24)) + followButton.constrain([ + followButton.constraint(.top, toView: contentView,constant: 159), + followButton.constraint(.centerX, toView: contentView) + ]) + } + + func config(with account: Mastodon.Entity.Account) { + displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName + acctLabel.text = account.acct + avatarImageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + headerImageView.af.setImage( + withURL: URL(string: account.header)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendAccountsCollectionViewCell() + cell.avatarImageView.backgroundColor = .white + cell.headerImageView.backgroundColor = .red + cell.displayNameLabel.text = "sunxiaojian" + cell.acctLabel.text = "sunxiaojian@mastodon.online" + return cell + } + .previewLayout(.fixed(width: 257, height: 202)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 108f2b6a2..adc9bd098 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -7,6 +7,7 @@ import Foundation import UIKit +import MastodonSDK class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let backgroundImageView: UIImageView = { @@ -18,8 +19,9 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let hashTagTitleLabel: UILabel = { let label = UILabel() label.textColor = .white - label.font = .preferredFont(forTextStyle: .caption1) + label.font = .systemFont(ofSize: 20, weight: .semibold) label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail return label }() @@ -57,20 +59,60 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { extension SearchRecommendTagsCollectionViewCell { private func configure() { + backgroundColor = Asset.Colors.buttonDefault.color + layer.cornerRadius = 8 + clipsToBounds = true + contentView.addSubview(backgroundImageView) backgroundImageView.constrain(toSuperviewEdges: nil) contentView.addSubview(hashTagTitleLabel) - hashTagTitleLabel.pinTopLeft(padding: 16) + hashTagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) contentView.addSubview(peopleLabel) - peopleLabel.constrain([ - peopleLabel.constraint(toTop: contentView, constant: 46), - peopleLabel.constraint(toLeading: contentView, constant: 16) - ]) + peopleLabel.pinTopLeft(top: 46, left: 16) contentView.addSubview(flameIconView) flameIconView.pinTopRight(padding: 16) } + + func config(with tag: Mastodon.Entity.Tag) { + hashTagTitleLabel.text = "# " + tag.name + if let peopleAreTalking = tag.history?.compactMap({ Int($0.uses)}).reduce(0, +) { + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + peopleLabel.text = string + } else { + peopleLabel.text = "" + } + } } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendTagsCollectionViewCell() + cell.hashTagTitleLabel.text = "# test" + cell.peopleLabel.text = "128 people are talking" + return cell + } + .previewLayout(.fixed(width: 228, height: 130)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+recomendView.swift index b498aa608..b62ebed7e 100644 --- a/Mastodon/Scene/Search/SearchViewController+recomendView.swift +++ b/Mastodon/Scene/Search/SearchViewController+recomendView.swift @@ -1,5 +1,5 @@ // -// SearchViewController+recommendView.swift +// SearchViewController+hashTagCollectionView.swift // Mastodon // // Created by sxiaojian on 2021/3/31. @@ -7,43 +7,121 @@ import Foundation import UIKit - +import OSLog +import MastodonSDK extension SearchViewController { - func setuprecommendView() { - recommendView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) - recommendView.dataSource = self - recommendView.delegate = self + func setupHashTagCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.HashTag.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashTagSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + hashTagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) + hashTagCollectionView.delegate = self + + stackView.addArrangedSubview(hashTagCollectionView) + hashTagCollectionView.constrain([ + hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + ]) + + viewModel.requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendHashTags.isEmpty { + let dataSource = RecomendHashTagSection.collectionViewDiffableDataSource(for: self.hashTagCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.hashTagDiffableDataSource = dataSource + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) } + func setupAccountsCollectionView() { + let header = SearchRecommendCollectionHeader() + header.titleLabel.text = L10n.Scene.Search.Recommend.Accounts.title + header.descriptionLabel.text = L10n.Scene.Search.Recommend.Accounts.description + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.accountSeeAllButtonPressed(_:)), for: .touchUpInside) + stackView.addArrangedSubview(header) + + accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) + accountsCollectionView.delegate = self + + stackView.addArrangedSubview(accountsCollectionView) + accountsCollectionView.constrain([ + accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) + ]) + + viewModel.requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.viewModel.recommendAccounts.isEmpty { + let dataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: self.accountsCollectionView) + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.viewModel.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.accountDiffableDataSource = dataSource + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - recommendView.collectionViewLayout.invalidateLayout() + hashTagCollectionView.collectionViewLayout.invalidateLayout() + accountsCollectionView.collectionViewLayout.invalidateLayout() } } extension SearchViewController: UICollectionViewDelegate { - + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + } } -extension SearchViewController: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return (self.viewModel.recommendAccounts.isEmpty ? 0 : 1) + (self.viewModel.recommendHashTags.isEmpty ? 0 : 1) +// MARK: - UICollectionViewDelegateFlowLayout +extension SearchViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + return UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - switch section { - case 0: - return viewModel.recommendHashTags.count - case 1: - return viewModel.recommendAccounts.count - default: - return 0 + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + if collectionView == hashTagCollectionView { + return 6 + } else { + return 12 } } - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - return UICollectionViewCell() + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + if collectionView == hashTagCollectionView { + return CGSize(width: 228, height: 130) + } else { + return CGSize(width: 257, height: 202) + } + } - +} + +extension SearchViewController { + @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) { + + } + + @objc func accountSeeAllButtonPressed(_ sender: UIButton) { + + } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f76f596c0..c51350665 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -7,6 +7,7 @@ import UIKit import Combine +import MastodonSDK final class SearchViewController: UIViewController, NeedsDependency { @@ -27,7 +28,40 @@ final class SearchViewController: UIViewController, NeedsDependency { return searchBar }() - let recommendView: UICollectionView = { + let scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.alwaysBounceVertical = true + scrollView.clipsToBounds = false + return scrollView + }() + + let stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 0 + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0) + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + let hashTagCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + + + let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) @@ -44,12 +78,35 @@ extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.search.color searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true - viewModel.requestRecommendData() + setupScrollView() + setupHashTagCollectionView() + setupAccountsCollectionView() + + } + func setupScrollView() { + view.addSubview(scrollView) + scrollView.constrain([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) + ]) + + scrollView.addSubview(stackView) + stackView.constrain([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) + } - } extension SearchViewController: UISearchBarDelegate { @@ -79,3 +136,20 @@ extension SearchViewController: UISearchBarDelegate { extension SearchViewController { } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchViewController_Previews: PreviewProvider { + + static var previews: some View { + UIViewControllerPreview { + let viewController = SearchViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) + } + +} + +#endif diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 679a2cfaf..40b22c880 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -5,14 +5,13 @@ // Created by sxiaojian on 2021/3/31. // -import Foundation import Combine +import Foundation import MastodonSDK -import UIKit import OSLog +import UIKit final class SearchViewModel { - var disposeBag = Set() // input @@ -25,30 +24,54 @@ final class SearchViewModel { var recommendAccounts = [Mastodon.Entity.Account]() init(context: AppContext) { - self.context = context + self.context = context } - func requestRecommendData() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let trendsAPI = context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: Mastodon.API.Trends.Query(limit: 5)) - - let accountsAPI = context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - Publishers.Zip(trendsAPI,accountsAPI) - .sink { completion in - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: zip request success", ((#file as NSString).lastPathComponent), #line, #function) - break - } - } receiveValue: { [weak self] (tags, accounts) in - guard let self = self else { return } - self.recommendAccounts = accounts.value - self.recommendHashTags = tags.value + func requestRecommendHashTags() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return } - .store(in: &disposeBag) + self.context.apiService.recommendTrends(domain: activeMastodonAuthenticationBox.domain, query: nil) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] tags in + guard let self = self else { return } + self.recommendHashTags = tags.value + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccounts() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + self.recommendAccounts = accounts.value + } + .store(in: &self.disposeBag) + } } } diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift new file mode 100644 index 000000000..5901b324a --- /dev/null +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -0,0 +1,89 @@ +// +// SearchRecommendCollectionHeader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/1. +// + +import Foundation +import UIKit + +class SearchRecommendCollectionHeader: UIView { + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = .black + label.font = .systemFont(ofSize: 20, weight: .semibold) + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.lightSecondaryText.color + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + let seeAllButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal) + return button + }() + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchRecommendCollectionHeader { + private func configure() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + titleLabel.pinTopLeft(top: 31, left: 16) + + addSubview(descriptionLabel) + descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16)) + + addSubview(seeAllButton) + seeAllButton.pinTopRight(top: 26, right: 16) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchRecommendCollectionHeader_Previews: PreviewProvider { + + static var controls: some View { + Group { + UIViewPreview() { + let cell = SearchRecommendCollectionHeader() + cell.titleLabel.text = "Trending in your timeline" + cell.descriptionLabel.text = "Hashtags that are getting quite a bit of attention among people you follow" + cell.seeAllButton.setTitle("See All", for: .normal) + return cell + } + .previewLayout(.fixed(width: 320, height: 116)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } + +} + +#endif diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 82fc9502b..db1913dc8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -17,7 +17,16 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/account/) - public class Account: Codable { + public class Account: Codable, Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { + return lhs.id == rhs.id + } + public typealias ID = String @@ -82,5 +91,6 @@ extension Mastodon.Entity { case muteExpiresAt = "mute_expires_at" } } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 9bf1a3a28..4e3a66400 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/history/) - public struct History: Codable { + public struct History: Codable, Hashable { /// UNIX timestamp on midnight of the given day public let day: Date public let uses: String diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 61c47ed68..867ff71a9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -16,7 +16,11 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/tag/) - public struct Tag: Codable { + public struct Tag: Codable, Hashable { + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + } + // Base public let name: String public let url: String From f24aee739e4ef04bbaa46cfaff939b781954a02a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 2 Apr 2021 12:10:12 +0800 Subject: [PATCH 166/400] chore: rename file name and code format --- Mastodon.xcodeproj/project.pbxproj | 8 ++-- ...hRecommendAccountsCollectionViewCell.swift | 14 +++---- ...earchRecommendTagsCollectionViewCell.swift | 9 ++--- ...> SearchViewController+RecomendView.swift} | 37 ++++++++----------- .../Scene/Search/SearchViewController.swift | 21 +++-------- .../SearchRecommendCollectionHeader.swift | 4 +- 6 files changed, 34 insertions(+), 59 deletions(-) rename Mastodon/Scene/Search/{SearchViewController+recomendView.swift => SearchViewController+RecomendView.swift} (91%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 19228f2e7..6a7995327 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,7 +30,7 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; - 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; @@ -344,7 +344,7 @@ 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+recomendView.swift"; sourceTree = ""; }; + 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+RecomendView.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; @@ -1439,7 +1439,7 @@ children = ( 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, - 2D34D9CA261489930081BFC0 /* SearchViewController+recomendView.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); @@ -2021,7 +2021,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, - 2D34D9CB261489930081BFC0 /* SearchViewController+recomendView.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index bed61f228..ba4babc52 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -6,8 +6,8 @@ // import Foundation -import UIKit import MastodonSDK +import UIKit class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let avatarImageView: UIImageView = { @@ -75,7 +75,7 @@ extension SearchRecommendAccountsCollectionViewCell { clipsToBounds = true contentView.addSubview(headerImageView) - headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0 ) + headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0) contentView.addSubview(avatarImageView) avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) @@ -86,20 +86,20 @@ extension SearchRecommendAccountsCollectionViewCell { contentView.addSubview(displayNameLabel) displayNameLabel.constrain([ - displayNameLabel.constraint(.top, toView: contentView,constant: 108), + displayNameLabel.constraint(.top, toView: contentView, constant: 108), displayNameLabel.constraint(.centerX, toView: contentView) ]) contentView.addSubview(acctLabel) acctLabel.constrain([ - acctLabel.constraint(.top, toView: contentView,constant: 132), + acctLabel.constraint(.top, toView: contentView, constant: 132), acctLabel.constraint(.centerX, toView: contentView) ]) contentView.addSubview(followButton) followButton.pin(toSize: CGSize(width: 76, height: 24)) followButton.constrain([ - followButton.constraint(.top, toView: contentView,constant: 159), + followButton.constraint(.top, toView: contentView, constant: 159), followButton.constraint(.centerX, toView: contentView) ]) } @@ -124,10 +124,9 @@ extension SearchRecommendAccountsCollectionViewCell { import SwiftUI struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { - static var controls: some View { Group { - UIViewPreview() { + UIViewPreview { let cell = SearchRecommendAccountsCollectionViewCell() cell.avatarImageView.backgroundColor = .white cell.headerImageView.backgroundColor = .red @@ -146,7 +145,6 @@ struct SearchRecommendAccountsCollectionViewCell_Previews: PreviewProvider { } .background(Color.gray) } - } #endif diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index adc9bd098..350720403 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -6,8 +6,8 @@ // import Foundation -import UIKit import MastodonSDK +import UIKit class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let backgroundImageView: UIImageView = { @@ -74,12 +74,11 @@ extension SearchRecommendTagsCollectionViewCell { contentView.addSubview(flameIconView) flameIconView.pinTopRight(padding: 16) - } func config(with tag: Mastodon.Entity.Tag) { hashTagTitleLabel.text = "# " + tag.name - if let peopleAreTalking = tag.history?.compactMap({ Int($0.uses)}).reduce(0, +) { + if let peopleAreTalking = tag.history?.compactMap({ Int($0.uses) }).reduce(0, +) { let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) peopleLabel.text = string } else { @@ -92,10 +91,9 @@ extension SearchRecommendTagsCollectionViewCell { import SwiftUI struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { - static var controls: some View { Group { - UIViewPreview() { + UIViewPreview { let cell = SearchRecommendTagsCollectionViewCell() cell.hashTagTitleLabel.text = "# test" cell.peopleLabel.text = "128 people are talking" @@ -112,7 +110,6 @@ struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { } .background(Color.gray) } - } #endif diff --git a/Mastodon/Scene/Search/SearchViewController+recomendView.swift b/Mastodon/Scene/Search/SearchViewController+RecomendView.swift similarity index 91% rename from Mastodon/Scene/Search/SearchViewController+recomendView.swift rename to Mastodon/Scene/Search/SearchViewController+RecomendView.swift index b62ebed7e..ca373b6b5 100644 --- a/Mastodon/Scene/Search/SearchViewController+recomendView.swift +++ b/Mastodon/Scene/Search/SearchViewController+RecomendView.swift @@ -1,14 +1,14 @@ // -// SearchViewController+hashTagCollectionView.swift +// SearchViewController+RecomendView.swift // Mastodon // // Created by sxiaojian on 2021/3/31. // import Foundation -import UIKit -import OSLog import MastodonSDK +import OSLog +import UIKit extension SearchViewController { func setupHashTagCollectionView() { @@ -17,15 +17,15 @@ extension SearchViewController { header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashTagSeeAllButtonPressed(_:)), for: .touchUpInside) stackView.addArrangedSubview(header) - + hashTagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) hashTagCollectionView.delegate = self - + stackView.addArrangedSubview(hashTagCollectionView) hashTagCollectionView.constrain([ hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) - + viewModel.requestRecommendHashTags() .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -39,10 +39,10 @@ extension SearchViewController { self.hashTagDiffableDataSource = dataSource } } receiveValue: { _ in - } .store(in: &disposeBag) } + func setupAccountsCollectionView() { let header = SearchRecommendCollectionHeader() header.titleLabel.text = L10n.Scene.Search.Recommend.Accounts.title @@ -71,11 +71,10 @@ extension SearchViewController { self.accountDiffableDataSource = dataSource } } receiveValue: { _ in - } .store(in: &disposeBag) } - + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() hashTagCollectionView.collectionViewLayout.invalidateLayout() @@ -85,16 +84,16 @@ extension SearchViewController { extension SearchViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", (#file as NSString).lastPathComponent, #line, #function, indexPath.debugDescription) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) } } // MARK: - UICollectionViewDelegateFlowLayout -extension SearchViewController: UICollectionViewDelegateFlowLayout { +extension SearchViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { @@ -104,24 +103,18 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { return 12 } } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { if collectionView == hashTagCollectionView { return CGSize(width: 228, height: 130) } else { return CGSize(width: 257, height: 202) } - } - } extension SearchViewController { - @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) { - - } - - @objc func accountSeeAllButtonPressed(_ sender: UIButton) { - - } + @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) {} + + @objc func accountSeeAllButtonPressed(_ sender: UIButton) {} } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index c51350665..856407f33 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -5,12 +5,11 @@ // Created by sxiaojian on 2021/3/31. // -import UIKit import Combine import MastodonSDK +import UIKit final class SearchViewController: UIViewController, NeedsDependency { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -57,10 +56,10 @@ final class SearchViewController: UIViewController, NeedsDependency { view.translatesAutoresizingMaskIntoConstraints = false return view }() + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? var accountDiffableDataSource: UICollectionViewDiffableDataSource? - let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal @@ -75,7 +74,6 @@ final class SearchViewController: UIViewController, NeedsDependency { } extension SearchViewController { - override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.search.color @@ -85,8 +83,8 @@ extension SearchViewController { setupScrollView() setupHashTagCollectionView() setupAccountsCollectionView() - } + func setupScrollView() { view.addSubview(scrollView) scrollView.constrain([ @@ -94,7 +92,7 @@ extension SearchViewController { scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) + scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), ]) scrollView.addSubview(stackView) @@ -105,7 +103,6 @@ extension SearchViewController { stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), ]) - } } @@ -128,20 +125,13 @@ extension SearchViewController: UISearchBarDelegate { viewModel.searchText.send(searchText) } - func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) { - - } -} - -extension SearchViewController { - + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} } #if canImport(SwiftUI) && DEBUG import SwiftUI struct SearchViewController_Previews: PreviewProvider { - static var previews: some View { UIViewControllerPreview { let viewController = SearchViewController() @@ -149,7 +139,6 @@ struct SearchViewController_Previews: PreviewProvider { } .previewLayout(.fixed(width: 375, height: 800)) } - } #endif diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index 5901b324a..02915ed25 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -62,10 +62,9 @@ extension SearchRecommendCollectionHeader { import SwiftUI struct SearchRecommendCollectionHeader_Previews: PreviewProvider { - static var controls: some View { Group { - UIViewPreview() { + UIViewPreview { let cell = SearchRecommendCollectionHeader() cell.titleLabel.text = "Trending in your timeline" cell.descriptionLabel.text = "Hashtags that are getting quite a bit of attention among people you follow" @@ -83,7 +82,6 @@ struct SearchRecommendCollectionHeader_Previews: PreviewProvider { } .background(Color.gray) } - } #endif From 4f77688d0393901269a934bb4600a429f209ac3f Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 16:38:33 +0800 Subject: [PATCH 167/400] feat: add nativation title view --- .../HashtagTimelineViewController.swift | 16 ++++-- .../View/HashtagTimelineTitleView.swift | 54 +++++++++++-------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index fd33cb883..58cf14d6d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -41,6 +41,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { let refreshControl = UIRefreshControl() + let titleView = HashtagTimelineNavigationBarTitleView() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } @@ -52,6 +54,9 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashTag)" + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil) + navigationItem.titleView = titleView + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color navigationItem.rightBarButtonItem = composeBarButtonItem @@ -133,20 +138,21 @@ extension HashtagTimelineViewController { } private func updatePromptTitle() { + var subtitle: String? + defer { + titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle) + } guard let histories = viewModel.hashtagEntity.value?.history else { - navigationItem.prompt = nil return } if histories.isEmpty { // No tag history, remove the prompt title - navigationItem.prompt = nil + return } else { let sortedHistory = histories.sorted { (h1, h2) -> Bool in return h1.day > h2.day } - if let accountsNumber = sortedHistory.first?.accounts { - navigationItem.prompt = L10n.Scene.Hashtag.prompt(accountsNumber) - } + subtitle = sortedHistory.first?.accounts } } } diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift index 04782bf63..78d5a971c 100644 --- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift +++ b/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift @@ -7,20 +7,26 @@ import UIKit -final class HashtagTimelineTitleView: UIView { +final class HashtagTimelineNavigationBarTitleView: UIView { let containerView = UIStackView() - let imageView = UIImageView() - let button = RoundedEdgesButton() - let label = UILabel() + let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() - // input - private var blockingState: HomeTimelineNavigationBarTitleViewModel.State? - weak var delegate: HomeTimelineNavigationBarTitleViewDelegate? - - // output - private(set) var state: HomeTimelineNavigationBarTitleViewModel.State = .logoImage + let subtitleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12) + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.isHidden = true + return label + }() override init(frame: CGRect) { super.init(frame: frame) @@ -34,8 +40,11 @@ final class HashtagTimelineTitleView: UIView { } -extension HomeTimelineNavigationBarTitleView { +extension HashtagTimelineNavigationBarTitleView { private func _init() { + containerView.axis = .vertical + containerView.alignment = .center + containerView.distribution = .fill containerView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerView) NSLayoutConstraint.activate([ @@ -45,15 +54,18 @@ extension HomeTimelineNavigationBarTitleView { containerView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - containerView.addArrangedSubview(imageView) - button.translatesAutoresizingMaskIntoConstraints = false - containerView.addArrangedSubview(button) - NSLayoutConstraint.activate([ - button.heightAnchor.constraint(equalToConstant: 24).priority(.defaultHigh) - ]) - containerView.addArrangedSubview(label) - - configure(state: .logoImage) - button.addTarget(self, action: #selector(HomeTimelineNavigationBarTitleView.buttonDidPressed(_:)), for: .touchUpInside) + containerView.addArrangedSubview(titleLabel) + containerView.addArrangedSubview(subtitleLabel) + } + + func updateTitle(hashtag: String, peopleNumber: String?) { + titleLabel.text = "#\(hashtag)" + if let peopleNumebr = peopleNumber { + subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) + subtitleLabel.isHidden = false + } else { + subtitleLabel.text = nil + subtitleLabel.isHidden = true + } } } From c0bd7c2497d9010205f6d06a30ba5db5a168e3f8 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 2 Apr 2021 16:50:07 +0800 Subject: [PATCH 168/400] chore: change .black to asset color. --- .../MastodonRegisterViewController.swift | 4 ++-- .../Register/MastodonRegisterViewModel.swift | 4 ++-- .../MastodonServerRulesViewController.swift | 2 +- .../MastodonServerRulesViewModel.swift | 2 +- ...SearchRecommendTagsCollectionViewCell.swift | 18 ++++++++++++++---- .../View/SearchRecommendCollectionHeader.swift | 2 +- 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 2bfa138b4..6079f4d0c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -58,7 +58,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let largeTitleLabel: UILabel = { let label = UILabel() label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = L10n.Scene.Register.title return label }() @@ -97,7 +97,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color return label }() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 45b4599a9..cd6106c23 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -185,9 +185,9 @@ extension MastodonRegisterViewModel { let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.checkmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? .black : .clear)) + attributeString.append(attributedStringImage(with: image, tintColor: validateState == .valid ? Asset.Colors.Label.primary.color : .clear)) attributeString.append(NSAttributedString(string: " ")) - let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]) + let eightCharactersDescription = NSAttributedString(string: L10n.Scene.Register.Input.Password.hint, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.Label.primary.color]) attributeString.append(eightCharactersDescription) return attributeString diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 86b9dc3eb..edc4c59e1 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -40,7 +40,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency let rulesLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .body) - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.text = "Rules" label.numberOfLines = 0 return label diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift index 14b4f0941..b1d000db5 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewModel.swift @@ -40,7 +40,7 @@ final class MastodonServerRulesViewModel { let imageName = String(i + 1) + ".circle.fill" let image = UIImage(systemName: imageName, withConfiguration: configuration)! let attachment = NSTextAttachment() - attachment.image = image.withTintColor(.black) + attachment.image = image.withTintColor(Asset.Colors.Label.primary.color) let imageAttribute = NSAttributedString(attachment: attachment) let ruleString = NSAttributedString(string: " " + rule.text + "\n\n") diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 350720403..685d214e6 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -78,12 +78,22 @@ extension SearchRecommendTagsCollectionViewCell { func config(with tag: Mastodon.Entity.Tag) { hashTagTitleLabel.text = "# " + tag.name - if let peopleAreTalking = tag.history?.compactMap({ Int($0.uses) }).reduce(0, +) { - let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) - peopleLabel.text = string - } else { + guard let historys = tag.history else { peopleLabel.text = "" + return } + var recentHistory = [Mastodon.Entity.History]() + for history in historys { + if Int(history.uses) == 0 { + break + } else { + recentHistory.append(history) + } + } + let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + peopleLabel.text = string + } } diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index 02915ed25..00efecd85 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -11,7 +11,7 @@ import UIKit class SearchRecommendCollectionHeader: UIView { let titleLabel: UILabel = { let label = UILabel() - label.textColor = .black + label.textColor = Asset.Colors.Label.primary.color label.font = .systemFont(ofSize: 20, weight: .semibold) return label }() From a9d35109fd73d5df2d84b0831ec5ad058ea7d39b Mon Sep 17 00:00:00 2001 From: jk234ert Date: Fri, 2 Apr 2021 17:05:06 +0800 Subject: [PATCH 169/400] feat: update mechanism of calculating number of people taking tags --- .../HashtagTimeline/HashtagTimelineViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 58cf14d6d..e82bb31ae 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -152,7 +152,11 @@ extension HashtagTimelineViewController { let sortedHistory = histories.sorted { (h1, h2) -> Bool in return h1.day > h2.day } - subtitle = sortedHistory.first?.accounts + let peopleTalkingNumber = sortedHistory + .prefix(2) + .compactMap({ Int($0.accounts) }) + .reduce(0, +) + subtitle = "\(peopleTalkingNumber)" } } } From 5d3b6d1943bf4f7bc290e23ce051f16055323210 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 18:13:45 +0800 Subject: [PATCH 170/400] feat: handle profile follow, block, and mute actions --- .../CoreData.xcdatamodel/contents | 10 +- CoreDataStack/Entity/MastodonUser.swift | 23 + Localization/app.json | 19 +- Mastodon.xcodeproj/project.pbxproj | 46 +- Mastodon/Coordinator/SceneCoordinator.swift | 18 + Mastodon/Diffiable/Section/PollSection.swift | 12 + .../CoreDataStack/MastodonUser.swift | 9 +- Mastodon/Generated/Assets.swift | 5 + Mastodon/Generated/Strings.swift | 40 ++ Mastodon/Helper/MastodonMetricFormatter.swift | 41 ++ .../Protocol/AvatarConfigurableView.swift | 28 +- .../Protocol/UserProvider/UserProvider.swift | 16 + .../UserProvider/UserProviderFacade.swift | 204 +++++++++ .../Profile/Banner/Contents.json | 9 + .../username.gray.colorset/Contents.json | 20 + .../Assets.xcassets/Profile/Contents.json | 9 + .../Resources/en.lproj/Localizable.strings | 11 + .../Header/ProfileHeaderViewController.swift | 34 +- .../View/ProfileFriendshipActionButton.swift | 71 --- .../Header/View/ProfileHeaderView.swift | 53 ++- .../ProfileRelationshipActionButton.swift | 40 ++ .../ProfileViewController+UserProvider.swift | 20 + .../Scene/Profile/ProfileViewController.swift | 420 +++++++++--------- Mastodon/Scene/Profile/ProfileViewModel.swift | 251 +++++++++-- .../Service/APIService/APIService+Block.swift | 167 +++++++ .../APIService/APIService+Follow.swift | 187 ++++++++ .../Service/APIService/APIService+Mute.swift | 167 +++++++ .../APIService/APIService+Relationship.swift | 84 ++-- .../APIService+CoreData+MastodonUser.swift | 1 + .../API/Mastodon+API+Account+Friendship.swift | 347 +++++++++++++++ 30 files changed, 1960 insertions(+), 402 deletions(-) create mode 100644 Mastodon/Helper/MastodonMetricFormatter.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProvider.swift create mode 100644 Mastodon/Protocol/UserProvider/UserProviderFacade.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Contents.json delete mode 100644 Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift create mode 100644 Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift create mode 100644 Mastodon/Service/APIService/APIService+Block.swift create mode 100644 Mastodon/Service/APIService/APIService+Follow.swift create mode 100644 Mastodon/Service/APIService/APIService+Mute.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index d655753d3..e2f059d50 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -69,6 +69,7 @@ + @@ -78,6 +79,7 @@ + @@ -197,12 +199,12 @@ - + - - + +
-
+
\ No newline at end of file diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 2228787b5..878eb9ad4 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -29,6 +29,9 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var followingCount: NSNumber @NSManaged public private(set) var followersCount: NSNumber + @NSManaged public private(set) var locked: Bool + @NSManaged public private(set) var bot: Bool + @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -88,6 +91,9 @@ extension MastodonUser { user.followingCount = NSNumber(value: property.followingCount) user.followersCount = NSNumber(value: property.followersCount) + user.locked = property.locked + user.bot = property.bot ?? false + // Mastodon do not provide relationship on the `Account` // Update relationship via attribute updating interface @@ -158,6 +164,17 @@ extension MastodonUser { self.followersCount = NSNumber(value: followersCount) } } + public func update(locked: Bool) { + if self.locked != locked { + self.locked = locked + } + } + public func update(bot: Bool) { + if self.bot != bot { + self.bot = bot + } + } + public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { if isFollowing { if !(self.followingBy ?? Set()).contains(mastodonUser) { @@ -249,6 +266,8 @@ extension MastodonUser { public let statusesCount: Int public let followingCount: Int public let followersCount: Int + public let locked: Bool + public let bot: Bool? public let createdAt: Date public let networkDate: Date @@ -268,6 +287,8 @@ extension MastodonUser { statusesCount: Int, followingCount: Int, followersCount: Int, + locked: Bool, + bot: Bool?, createdAt: Date, networkDate: Date ) { @@ -286,6 +307,8 @@ extension MastodonUser { self.statusesCount = statusesCount self.followingCount = followingCount self.followersCount = followersCount + self.locked = locked + self.bot = bot self.createdAt = createdAt self.networkDate = networkDate } diff --git a/Localization/app.json b/Localization/app.json index 812a801ac..9eaba58f4 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,9 +69,16 @@ "firendship": { "follow": "Follow", "following": "Following", + "pending": "Pending", "block": "Block", + "block_user": "Block %s", + "unblock": "Unblock", + "unblock_user": "Unblock %s", "blocked": "Blocked", "mute": "Mute", + "mute_user": "Mute %s", + "unmute": "Unmute", + "unmute_user": "Unmute %s", "muted": "Muted", "edit_info": "Edit info" }, @@ -257,6 +264,16 @@ "posts": "Posts", "replies": "Replies", "media": "Media" + }, + "relationship_action_alert": { + "confirm_unmute_user": { + "title": "Unmute Account", + "message": "Confirm unmute %s" + }, + "confirm_unblock_usre": { + "title": "Unblock Account", + "message": "Confirm unblock %s" + } } }, "search": { @@ -266,4 +283,4 @@ } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e86ad664..2d5d0486b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -134,7 +134,7 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */; }; + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB3D0FF225BAA61700EAA174 /* AlamofireImage */; }; @@ -259,6 +259,13 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */; }; + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */; }; + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -473,7 +480,7 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewPreview.swift; sourceTree = ""; }; DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewModel.swift; sourceTree = ""; }; DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollExpiresOptionCollectionViewCell.swift; sourceTree = ""; }; - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFriendshipActionButton.swift; sourceTree = ""; }; + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRelationshipActionButton.swift; sourceTree = ""; }; DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldView.swift; sourceTree = ""; }; DB35FC2E26130172006193C9 /* MastodonField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonField.swift; sourceTree = ""; }; DB3D0FED25BAA42200EAA174 /* MastodonSDK */ = {isa = PBXFileReference; lastKnownFileType = folder; path = MastodonSDK; sourceTree = ""; }; @@ -604,6 +611,13 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; + DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProviderFacade.swift; sourceTree = ""; }; + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Block.swift"; sourceTree = ""; }; + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -885,6 +899,7 @@ isa = PBXGroup; children = ( 2D38F1FC25CD47D900561493 /* StatusProvider */, + DBAE3F742615DD63004B8251 /* UserProvider */, DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */, 2D69CFF325CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift */, 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */, @@ -1190,6 +1205,9 @@ 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, + DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, ); path = APIService; sourceTree = ""; @@ -1400,8 +1418,8 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, + 5D03938E2612D200007FE196 /* Webview */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, @@ -1503,6 +1521,7 @@ DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, + DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, @@ -1537,6 +1556,7 @@ children = ( 2D42FF6A25C817D2004A627A /* MastodonStatusContent.swift */, DB35FC2E26130172006193C9 /* MastodonField.swift */, + DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */, ); path = Helper; sourceTree = ""; @@ -1558,6 +1578,15 @@ path = View; sourceTree = ""; }; + DBAE3F742615DD63004B8251 /* UserProvider */ = { + isa = PBXGroup; + children = ( + DBAE3F672615DD60004B8251 /* UserProvider.swift */, + DBAE3F872615DDF4004B8251 /* UserProviderFacade.swift */, + ); + path = UserProvider; + sourceTree = ""; + }; DBB525132611EBB1002F1F29 /* Segmented */ = { isa = PBXGroup; children = ( @@ -1603,7 +1632,7 @@ DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */, DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */, - DB35FC1E2612F1D9006193C9 /* ProfileFriendshipActionButton.swift */, + DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */, DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */, ); path = View; @@ -1990,6 +2019,7 @@ DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */, @@ -2028,6 +2058,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -2096,6 +2127,7 @@ DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, + DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2106,7 +2138,7 @@ 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */, - DB35FC1F2612F1D9006193C9 /* ProfileFriendshipActionButton.swift in Sources */, + DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */, DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */, DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */, DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */, @@ -2128,6 +2160,7 @@ DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, @@ -2152,12 +2185,14 @@ DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, + DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, @@ -2216,6 +2251,7 @@ DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index d578ee528..4b6eed7ba 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -56,6 +56,7 @@ extension SceneCoordinator { // misc case alertController(alertController: UIAlertController) + case safari(url: URL) #if DEBUG case publicTimeline @@ -111,6 +112,17 @@ extension SceneCoordinator { guard var presentingViewController = sender ?? sceneDelegate.window?.rootViewController?.topMost else { return nil } + // adapt for child controller + if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { + switch viewController { + case is ProfileViewController: + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem + default: + navigationControllerVisibleViewController.navigationItem.backBarButtonItem = nil + } + } if let mainTabBarController = presentingViewController as? MainTabBarController, let navigationController = mainTabBarController.selectedViewController as? UINavigationController, @@ -222,6 +234,12 @@ private extension SceneCoordinator { ) } viewController = alertController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Diffiable/Section/PollSection.swift b/Mastodon/Diffiable/Section/PollSection.swift index 2f9404410..044f4fb9d 100644 --- a/Mastodon/Diffiable/Section/PollSection.swift +++ b/Mastodon/Diffiable/Section/PollSection.swift @@ -9,6 +9,18 @@ import UIKit import CoreData import CoreDataStack +import MastodonSDK + +extension Mastodon.Entity.Attachment: Hashable { + public static func == (lhs: Mastodon.Entity.Attachment, rhs: Mastodon.Entity.Attachment) -> Bool { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + enum PollSection: Equatable, Hashable { case main } diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 471adb815..e140ab95a 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -26,6 +26,8 @@ extension MastodonUser.Property { statusesCount: entity.statusesCount, followingCount: entity.followingCount, followersCount: entity.followersCount, + locked: entity.locked, + bot: entity.bot, createdAt: entity.createdAt, networkDate: networkDate ) @@ -39,7 +41,12 @@ extension MastodonUser { } var acctWithDomain: String { - return username + "@" + domain + if !acct.contains("@") { + // Safe concat due to username cannot contains "@" + return username + "@" + domain + } else { + return acct + } } } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 8276cfb20..0abcc2341 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -93,6 +93,11 @@ internal enum Asset { internal enum Connectivity { internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") } + internal enum Profile { + internal enum Banner { + internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") + } + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a308033fc..53ef603e2 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -92,6 +92,10 @@ internal enum L10n { internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") + /// Block %@ + internal static func blockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockUser", String(describing: p1)) + } /// Edit info internal static let editInfo = L10n.tr("Localizable", "Common.Controls.Firendship.EditInfo") /// Follow @@ -102,6 +106,24 @@ internal enum L10n { internal static let mute = L10n.tr("Localizable", "Common.Controls.Firendship.Mute") /// Muted internal static let muted = L10n.tr("Localizable", "Common.Controls.Firendship.Muted") + /// Mute %@ + internal static func muteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.MuteUser", String(describing: p1)) + } + /// Pending + internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Unblock + internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") + /// Unblock %@ + internal static func unblockUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnblockUser", String(describing: p1)) + } + /// Unmute + internal static let unmute = L10n.tr("Localizable", "Common.Controls.Firendship.Unmute") + /// Unmute %@ + internal static func unmuteUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.UnmuteUser", String(describing: p1)) + } } internal enum Status { /// Tap to reveal that may be sensitive @@ -290,6 +312,24 @@ internal enum L10n { /// posts internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") } + internal enum RelationshipActionAlert { + internal enum ConfirmUnblockUsre { + /// Confirm unblock %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message", String(describing: p1)) + } + /// Unblock Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title") + } + internal enum ConfirmUnmuteUser { + /// Confirm unmute %@ + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message", String(describing: p1)) + } + /// Unmute Account + internal static let title = L10n.tr("Localizable", "Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title") + } + } internal enum SegmentedControl { /// Media internal static let media = L10n.tr("Localizable", "Scene.Profile.SegmentedControl.Media") diff --git a/Mastodon/Helper/MastodonMetricFormatter.swift b/Mastodon/Helper/MastodonMetricFormatter.swift new file mode 100644 index 000000000..0711669fb --- /dev/null +++ b/Mastodon/Helper/MastodonMetricFormatter.swift @@ -0,0 +1,41 @@ +// +// MastodonMetricFormatter.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import Foundation + +final class MastodonMetricFormatter: Formatter { + + func string(from number: Int) -> String? { + let isPositive = number >= 0 + let symbol = isPositive ? "" : "-" + + let numberFormatter = NumberFormatter() + + let value = abs(number) + let metric: String + + switch value { + case 0..<1000: // 0 ~ 1K + metric = String(value) + case 1000..<10000: // 1K ~ 10K + numberFormatter.maximumFractionDigits = 1 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + case 10000..<1000000: // 10K ~ 1M + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + metric = string + "K" + default: + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000) + metric = string + "M" + } + + return symbol + metric + } + +} diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 6391066e1..b8c5285a2 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -84,6 +84,8 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarImageView, configuration: configuration) } if let avatarButton = configurableAvatarButton { @@ -110,9 +112,24 @@ extension AvatarConfigurableView { completion: nil ) } + + configureLayerBorder(view: avatarButton, configuration: configuration) } } + func configureLayerBorder(view: UIView, configuration: AvatarConfigurableViewConfiguration) { + guard let borderWidth = configuration.borderWidth, borderWidth > 0, + let borderColor = configuration.borderColor else { + return + } + + view.layer.masksToBounds = true + view.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = borderColor.cgColor + view.layer.borderWidth = borderWidth + } + func avatarConfigurableView(_ avatarConfigurableView: AvatarConfigurableView, didFinishConfiguration configuration: AvatarConfigurableViewConfiguration) { } } @@ -121,10 +138,19 @@ struct AvatarConfigurableViewConfiguration { let avatarImageURL: URL? let placeholderImage: UIImage? + let borderColor: UIColor? + let borderWidth: CGFloat? - init(avatarImageURL: URL?, placeholderImage: UIImage? = nil) { + init( + avatarImageURL: URL?, + placeholderImage: UIImage? = nil, + borderColor: UIColor? = nil, + borderWidth: CGFloat? = nil + ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage + self.borderColor = borderColor + self.borderWidth = borderWidth } } diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift new file mode 100644 index 000000000..63a1f8e68 --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -0,0 +1,16 @@ +// +// UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { + // async + func mastodonUser() -> Future +} diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift new file mode 100644 index 000000000..04297772b --- /dev/null +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -0,0 +1,204 @@ +// +// UserProviderFacade.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +enum UserProviderFacade { } + +extension UserProviderFacade { + + static func toggleUserFollowRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserFollowRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserFollowRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserBlockRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserBlockRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleBlock( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func toggleUserMuteRelationship( + provider: UserProvider + ) -> AnyPublisher, Error> { + // prepare authentication + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() + } + + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } + + private static func _toggleUserMuteRelationship( + context: AppContext, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + mastodonUser: AnyPublisher + ) -> AnyPublisher, Error> { + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + + return context.apiService.toggleMute( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .switchToLatest() + .eraseToAnyPublisher() + } + +} + +extension UserProviderFacade { + + static func createProfileActionMenu( + for mastodonUser: MastodonUser, + isMuting: Bool, + isBlocking: Bool, + provider: UserProvider + ) -> UIMenu { + var children: [UIMenuElement] = [] + let name = mastodonUser.displayNameWithFallback + + // mute + let muteAction = UIAction( + title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, + image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), + discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), + attributes: isMuting ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserMuteRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isMuting { + children.append(muteAction) + } else { + let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) + children.append(muteMenu) + } + + // block + let blockAction = UIAction( + title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, + image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), + discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), + attributes: isBlocking ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } + + UserProviderFacade.toggleUserBlockRelationship( + provider: provider + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isBlocking { + children.append(blockAction) + } else { + let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) + children.append(blockMenu) + } + + return UIMenu(title: "", options: [], children: children) + } + +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json new file mode 100644 index 000000000..473d42adc --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0.961", + "green" : "0.922", + "red" : "0.922" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 88ecd2508..4a9b7bd30 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -29,12 +29,19 @@ Please check your internet connection."; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; "Common.Controls.Firendship.Follow" = "Follow"; "Common.Controls.Firendship.Following" = "Following"; "Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -96,6 +103,10 @@ tap the link to confirm your account."; "Scene.Profile.Dashboard.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 58a7a6110..855581902 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -21,7 +21,7 @@ final class ProfileHeaderViewController: UIViewController { weak var delegate: ProfileHeaderViewControllerDelegate? - let profileBannerView = ProfileHeaderView() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) segmenetedControl.selectedSegmentIndex = 0 @@ -31,7 +31,7 @@ final class ProfileHeaderViewController: UIViewController { private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 - private var isAdjustBannerImageViewForSafeAreaInset = false + // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero deinit { @@ -47,19 +47,19 @@ extension ProfileHeaderViewController { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - profileBannerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(profileBannerView) + profileHeaderView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ - profileBannerView.topAnchor.constraint(equalTo: view.topAnchor), - profileBannerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - profileBannerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), + profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) - profileBannerView.preservesSuperviewLayoutMargins = true + profileHeaderView.preservesSuperviewLayoutMargins = true pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false view.addSubview(pageSegmentedControl) NSLayoutConstraint.activate([ - pageSegmentedControl.topAnchor.constraint(equalTo: profileBannerView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), + pageSegmentedControl.topAnchor.constraint(equalTo: profileHeaderView.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), pageSegmentedControl.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), pageSegmentedControl.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), view.bottomAnchor.constraint(equalTo: pageSegmentedControl.bottomAnchor, constant: ProfileHeaderViewController.segmentedControlMarginHeight), @@ -72,11 +72,13 @@ extension ProfileHeaderViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !isAdjustBannerImageViewForSafeAreaInset { - isAdjustBannerImageViewForSafeAreaInset = true - profileBannerView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top - profileBannerView.bannerImageView.frame.size.height += containerSafeAreaInset.top - } + // Deprecated: + // not needs this tweak due to force layout update in the parent + // if !isAdjustBannerImageViewForSafeAreaInset { + // isAdjustBannerImageViewForSafeAreaInset = true + // profileHeaderView.bannerImageView.frame.origin.y = -containerSafeAreaInset.top + // profileHeaderView.bannerImageView.frame.size.height += containerSafeAreaInset.top + // } } override func viewDidLayoutSubviews() { @@ -115,13 +117,13 @@ extension ProfileHeaderViewController { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) updateHeaderBottomShadow(progress: progress) - let bannerImageView = profileBannerView.bannerImageView + let bannerImageView = profileHeaderView.bannerImageView guard bannerImageView.bounds != .zero else { // wait layout finish return } - let bannerContainerInWindow = profileBannerView.convert(profileBannerView.bannerContainerView.frame, to: nil) + let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift deleted file mode 100644 index 286145ffd..000000000 --- a/Mastodon/Scene/Profile/Header/View/ProfileFriendshipActionButton.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// ProfileFriendshipActionButton.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-30. -// - -import UIKit - -final class ProfileFriendshipActionButton: RoundedEdgesButton { - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} - -extension ProfileFriendshipActionButton { - private func _init() { - configure(state: .follow) - } -} - -extension ProfileFriendshipActionButton { - enum State { - case follow - case following - case blocked - case muted - case edit - case editing - - var title: String { - switch self { - case .follow: return L10n.Common.Controls.Firendship.follow - case .following: return L10n.Common.Controls.Firendship.following - case .blocked: return L10n.Common.Controls.Firendship.blocked - case .muted: return L10n.Common.Controls.Firendship.muted - case .edit: return L10n.Common.Controls.Firendship.editInfo - case .editing: return L10n.Common.Controls.Actions.done - } - } - - var backgroundColor: UIColor { - switch self { - case .follow: return Asset.Colors.Button.normal.color - case .following: return Asset.Colors.Button.normal.color - case .blocked: return Asset.Colors.Background.danger.color - case .muted: return Asset.Colors.Background.alertYellow.color - case .edit: return Asset.Colors.Button.normal.color - case .editing: return Asset.Colors.Button.normal.color - } - } - } - - private func configure(state: State) { - setTitle(state.title, for: .normal) - setTitleColor(.white, for: .normal) - setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor), for: .normal) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: state.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - } -} - diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 7fac52896..a6b1f275c 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,6 +10,7 @@ import UIKit import ActiveLabel protocol ProfileHeaderViewDelegate: class { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) @@ -22,6 +23,7 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) + static let bannerImageViewPlaceholderColor = UIColor.systemGray weak var delegate: ProfileHeaderViewDelegate? @@ -29,10 +31,18 @@ final class ProfileHeaderView: UIView { let bannerImageView: UIImageView = { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill - imageView.image = .placeholder(color: .systemGray) + imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) imageView.layer.masksToBounds = true + // #if DEBUG + // imageView.image = .placeholder(color: .red) + // #endif return imageView }() + let bannerImageViewOverlayView: UIView = { + let overlayView = UIView() + overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + return overlayView + }() let avatarImageView: UIImageView = { let imageView = UIImageView() @@ -59,14 +69,18 @@ final class ProfileHeaderView: UIView { label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 - label.textColor = .white + label.textColor = Asset.Profile.Banner.usernameGray.color label.text = "@alice" label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return label }() let statusDashboardView = ProfileStatusDashboardView() - let friendshipActionButton = ProfileFriendshipActionButton() + let relationshipActionButton: ProfileRelationshipActionButton = { + let button = ProfileRelationshipActionButton() + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + return button + }() let bioContainerView = UIView() let fieldContainerStackView = UIStackView() @@ -103,6 +117,15 @@ extension ProfileHeaderView { bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] bannerImageView.frame = bannerContainerView.bounds bannerContainerView.addSubview(bannerImageView) + + bannerImageViewOverlayView.translatesAutoresizingMaskIntoConstraints = false + bannerImageView.addSubview(bannerImageViewOverlayView) + NSLayoutConstraint.activate([ + bannerImageViewOverlayView.topAnchor.constraint(equalTo: bannerImageView.topAnchor), + bannerImageViewOverlayView.leadingAnchor.constraint(equalTo: bannerImageView.leadingAnchor), + bannerImageViewOverlayView.trailingAnchor.constraint(equalTo: bannerImageView.trailingAnchor), + bannerImageViewOverlayView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor), + ]) // avatar avatarImageView.translatesAutoresizingMaskIntoConstraints = false @@ -156,14 +179,14 @@ extension ProfileHeaderView { statusDashboardView.bottomAnchor.constraint(equalTo: dashboardContainerView.bottomAnchor), ]) - friendshipActionButton.translatesAutoresizingMaskIntoConstraints = false - dashboardContainerView.addSubview(friendshipActionButton) + relationshipActionButton.translatesAutoresizingMaskIntoConstraints = false + dashboardContainerView.addSubview(relationshipActionButton) NSLayoutConstraint.activate([ - friendshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), - friendshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), - friendshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), - friendshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), - friendshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), + relationshipActionButton.topAnchor.constraint(equalTo: dashboardContainerView.topAnchor), + relationshipActionButton.leadingAnchor.constraint(greaterThanOrEqualTo: statusDashboardView.trailingAnchor, constant: 8), + relationshipActionButton.trailingAnchor.constraint(equalTo: dashboardContainerView.readableContentGuide.trailingAnchor), + relationshipActionButton.widthAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.width).priority(.defaultHigh), + relationshipActionButton.heightAnchor.constraint(equalToConstant: ProfileHeaderView.friendshipActionButtonSize.height).priority(.defaultHigh), ]) bioContainerView.preservesSuperviewLayoutMargins = true @@ -184,10 +207,20 @@ extension ProfileHeaderView { bringSubviewToFront(nameContainerStackView) bioActiveLabel.delegate = self + + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) } } +extension ProfileHeaderView { + @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + assert(sender === relationshipActionButton) + delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) + } +} + // MARK: - ActiveLabelDelegate extension ProfileHeaderView: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift new file mode 100644 index 000000000..b098c1ec1 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -0,0 +1,40 @@ +// +// ProfileRelationshipActionButton.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-30. +// + +import UIKit + +final class ProfileRelationshipActionButton: RoundedEdgesButton { + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ProfileRelationshipActionButton { + private func _init() { + // do nothing + } +} + +extension ProfileRelationshipActionButton { + func configure(actionOptionSet: ProfileViewModel.RelationshipActionOptionSet) { + setTitle(actionOptionSet.title, for: .normal) + setTitleColor(.white, for: .normal) + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + } +} + diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift new file mode 100644 index 000000000..3a26db1c1 --- /dev/null +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -0,0 +1,20 @@ +// +// ProfileViewController+UserProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-1. +// + +import Foundation +import Combine +import CoreDataStack + +extension ProfileViewController: UserProvider { + + func mastodonUser() -> Future { + return Future { promise in + promise(.success(self.viewModel.mastodonUser.value)) + } + } + +} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 4b74ad632..57a398b4b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,11 +18,17 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! - private var preferredStatusBarStyleForBanner: UIStatusBarStyle = .lightContent { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + let moreMenuBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + barButtonItem.tintColor = .white + return barButtonItem + }() let refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() @@ -78,7 +84,7 @@ extension ProfileViewController { height: bottomPageHeight + headerViewHeight ) self.overlayScrollView.contentSize = contentSize - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) + // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) } } @@ -86,7 +92,7 @@ extension ProfileViewController { extension ProfileViewController { override var preferredStatusBarStyle: UIStatusBarStyle { - return preferredStatusBarStyleForBanner + return .lightContent } override func viewSafeAreaInsetsDidChange() { @@ -95,25 +101,45 @@ extension ProfileViewController { profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) } + override var isViewLoaded: Bool { + return super.isViewLoaded + } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + let barAppearance = UINavigationBarAppearance() barAppearance.configureWithTransparentBackground() navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance + navigationItem.titleView = UIView() -// if navigationController?.viewControllers.first == self { -// navigationItem.leftBarButtonItem = avatarBarButtonItem -// } -// avatarBarButtonItem.avatarButton.addTarget(self, action: #selector(ProfileViewController.avatarButtonPressed(_:)), for: .touchUpInside) - -// unmuteMenuBarButtonItem.target = self -// unmuteMenuBarButtonItem.action = #selector(ProfileViewController.unmuteBarButtonItemPressed(_:)) + Publishers.CombineLatest( + viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), + viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + guard let self = self else { return } + var items: [UIBarButtonItem] = [] + if !isReplyBarButtonItemHidden { + items.append(self.replyBarButtonItem) + } + if !isMoreMenuBarButtonItemHidden { + items.append(self.moreMenuBarButtonItem) + } + guard !items.isEmpty else { + self.navigationItem.rightBarButtonItems = nil + return + } + self.navigationItem.rightBarButtonItems = items + } + .store(in: &disposeBag) + // Publishers.CombineLatest4( // viewModel.muted.eraseToAnyPublisher(), @@ -244,23 +270,15 @@ extension ProfileViewController { profileHeaderViewController.delegate = self profileSegmentedViewController.pagingViewController.pagingDelegate = self -// // add segmented bar to header -// profileSegmentedViewController.pagingViewController.addBar( -// bar, -// dataSource: profileSegmentedViewController.pagingViewController.viewModel, -// at: .custom(view: profileHeaderViewController.view, layout: { bar in -// bar.translatesAutoresizingMaskIntoConstraints = false -// self.profileHeaderViewController.view.addSubview(bar) -// NSLayoutConstraint.activate([ -// bar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), -// bar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), -// bar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), -// bar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.headerMinHeight).priority(.defaultHigh), -// ]) -// }) -// ) - // bind view model + viewModel.name + .receive(on: DispatchQueue.main) + .sink { [weak self] name in + guard let self = self else { return } + self.title = name + } + .store(in: &disposeBag) + Publishers.CombineLatest( viewModel.bannerImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -268,56 +286,29 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] bannerImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: Asset.Colors.Background.systemGroupedBackground.color) + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileBannerView.bannerImageView.image = placeholder + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder return } - self.profileHeaderViewController.profileBannerView.bannerImageView.af.setImage( + self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( withURL: bannerImageURL, placeholderImage: placeholder, imageTransition: .crossDissolve(0.3), runImageTransitionIfCached: false, completion: { [weak self] response in guard let self = self else { return } - switch response.result { - case .success(let image): - self.viewModel.headerDomainLumaStyle.value = image.domainLumaCoefficientsStyle ?? .dark - case .failure: - break + guard let image = response.value else { return } + guard image.size.width > 1 && image.size.height > 1 else { + // restore to placeholder when image invalid + self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder + return } } ) } .store(in: &disposeBag) - viewModel.headerDomainLumaStyle - .receive(on: DispatchQueue.main) - .sink { [weak self] style in - guard let self = self else { return } - let textColor: UIColor - let shadowColor: UIColor - switch style { - case .light: - self.preferredStatusBarStyleForBanner = .darkContent - textColor = .black - shadowColor = .white - case .dark: - self.preferredStatusBarStyleForBanner = .lightContent - textColor = .white - shadowColor = .black - default: - self.preferredStatusBarStyleForBanner = .default - textColor = .white - shadowColor = .black - } - - self.profileHeaderViewController.profileBannerView.nameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.usernameLabel.textColor = textColor - self.profileHeaderViewController.profileBannerView.nameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - self.profileHeaderViewController.profileBannerView.usernameLabel.applyShadow(color: shadowColor, alpha: 0.5, x: 0, y: 2, blur: 2) - } - .store(in: &disposeBag) Publishers.CombineLatest( viewModel.avatarImageURL.eraseToAnyPublisher(), viewModel.viewDidAppear.eraseToAnyPublisher() @@ -325,147 +316,100 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] avatarImageURL, _ in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.configure( - with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL) + self.profileHeaderViewController.profileHeaderView.configure( + with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2) ) } .store(in: &disposeBag) -// viewModel.protected -// .map { $0 != true } -// .assign(to: \.isHidden, on: profileHeaderViewController.profileBannerView.lockImageView) -// .store(in: &disposeBag) viewModel.name .map { $0 ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.nameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) .store(in: &disposeBag) viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileBannerView.usernameLabel) + .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) .store(in: &disposeBag) -// viewModel.friendship -// .sink { [weak self] friendship in -// guard let self = self else { return } -// let followingButton = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followActionButton -// followingButton.isHidden = friendship == nil -// -// if let friendship = friendship { -// switch friendship { -// case .following: followingButton.style = .following -// case .pending: followingButton.style = .pending -// case .none: followingButton.style = .follow -// } -// } -// } -// .store(in: &disposeBag) -// viewModel.followedBy -// .sink { [weak self] followedBy in -// guard let self = self else { return } -// let followStatusLabel = self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.followStatusLabel -// followStatusLabel.isHidden = followedBy != true -// } -// .store(in: &disposeBag) -// + viewModel.relationshipActionOptionSet + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionOptionSet in + guard let self = self else { return } + guard let mastodonUser = self.viewModel.mastodonUser.value else { + self.moreMenuBarButtonItem.menu = nil + return + } + let isMuting = relationshipActionOptionSet.contains(.muting) + let isBlocking = relationshipActionOptionSet.contains(.blocking) + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self) + } + .store(in: &disposeBag) + viewModel.isRelationshipActionButtonHidden + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + guard let self = self else { return } + self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden + } + .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), + viewModel.isEditing.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionSet, isEditing in + guard let self = self else { return } + let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton + if relationshipActionSet.contains(.edit) { + friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit) + } else { + friendshipButton.configure(actionOptionSet: relationshipActionSet) + } + } + .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] bio in guard let self = self else { return } - self.profileHeaderViewController.profileBannerView.bioActiveLabel.configure(note: bio ?? "") + self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "") }) .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.url.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] url, isSuspended in -// guard let self = self else { return } -// let url = url.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.linkButton.setTitle(url, for: .normal) -// let isEmpty = url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.linkContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) -// Publishers.CombineLatest( -// viewModel.location.eraseToAnyPublisher(), -// viewModel.suspended.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] location, isSuspended in -// guard let self = self else { return } -// let location = location.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) } ?? " " -// self.profileHeaderViewController.profileBannerView.geoButton.setTitle(location, for: .normal) -// let isEmpty = location.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// self.profileHeaderViewController.profileBannerView.geoContainer.isHidden = isEmpty || isSuspended -// } -// .store(in: &disposeBag) viewModel.statusesCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followingCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) viewModel.followersCount .sink { [weak self] count in guard let self = self else { return } - let text = count.flatMap { String($0) } ?? "-" - self.profileHeaderViewController.profileBannerView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text } .store(in: &disposeBag) -// viewModel.followersCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.followersStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.listedCount -// .sink { [weak self] count in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.listedStatusItemView.countLabel.text = count.flatMap { "\($0)" } ?? "-" -// } -// .store(in: &disposeBag) -// viewModel.suspended -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isSuspended in -// guard let self = self else { return } -// self.profileHeaderViewController.profileBannerView.profileBannerStatusView.isHidden = isSuspended -// self.profileHeaderViewController.profileBannerView.profileBannerInfoActionView.isHidden = isSuspended -// if isSuspended { -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileTweetPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileMediaPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserMediaTimelineViewModel.State.Suspended.self) -// self.profileSegmentedViewController -// .pagingViewController.viewModel -// .profileLikesPostTimelineViewController.viewModel -// .stateMachine -// .enter(UserLikeTimelineViewModel.State.Suspended.self) -// } -// } -// .store(in: &disposeBag) - -// - profileHeaderViewController.profileBannerView.delegate = self + + profileHeaderViewController.profileHeaderView.delegate = self } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // set back button tint color in SceneCoordinator.present(scene:from:transition:) + + // force layout to make banner image tweak take effect + view.layoutIfNeeded() + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + viewModel.viewDidAppear.send() // set overlay scroll view initial content size @@ -483,6 +427,11 @@ extension ProfileViewController { extension ProfileViewController { + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + // TODO: + } + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -600,62 +549,97 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // setup observer and gesture fallback currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: postTimelineViewController.scrollView) postTimelineViewController.scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - - -// if let userMediaTimelineViewController = postTimelineViewController as? UserMediaTimelineViewController, -// let currentState = userMediaTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserMediaTimelineViewModel.State.NoMore, -// is UserMediaTimelineViewModel.State.NotAuthorized, -// is UserMediaTimelineViewModel.State.Blocked: -// break -// default: -// if userMediaTimelineViewController.viewModel.items.value.isEmpty { -// userMediaTimelineViewController.viewModel.stateMachine.enter(UserMediaTimelineViewModel.State.Reloading.self) -// } -// } -// } -// -// if let userLikeTimelineViewController = postTimelineViewController as? UserLikeTimelineViewController, -// let currentState = userLikeTimelineViewController.viewModel.stateMachine.currentState { -// switch currentState { -// case is UserLikeTimelineViewModel.State.NoMore, -// is UserLikeTimelineViewModel.State.NotAuthorized, -// is UserLikeTimelineViewModel.State.Blocked: -// break -// default: -// if userLikeTimelineViewController.viewModel.items.value.isEmpty { -// userLikeTimelineViewController.viewModel.stateMachine.enter(UserLikeTimelineViewModel.State.Reloading.self) -// } -// } -// } } } -// MARK: - ProfileBannerInfoActionViewDelegate -//extension ProfileViewController: ProfileBannerInfoActionViewDelegate { -// -// func profileBannerInfoActionView(_ profileBannerInfoActionView: ProfileBannerInfoActionView, followActionButtonPressed button: FollowActionButton) { -// UserProviderFacade -// .toggleUserFriendship(provider: self, sender: button) -// .sink { _ in -// // do nothing -// } receiveValue: { _ in -// // do nothing -// } -// .store(in: &disposeBag) -// } -// -//} - // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { + let relationshipActionSet = viewModel.relationshipActionOptionSet.value + if relationshipActionSet.contains(.edit) { + viewModel.isEditing.value.toggle() + } else { + guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } + switch relationshipAction { + case .none: + break + case .follow, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + + } receiveValue: { _ in + + } + .store(in: &disposeBag) + case .pending: + break + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), + preferredStyle: .alert + ) + let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserMuteRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unmuteAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocking: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), + preferredStyle: .alert + ) + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserBlockRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocked: + break + default: + assertionFailure() + } + + } } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { + switch entity.type { + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + // TODO: + break + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index f7248009d..5df8952b8 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -26,14 +26,12 @@ class ProfileViewModel: NSObject { let mastodonUser: CurrentValueSubject let currentMastodonUser = CurrentValueSubject(nil) let viewDidAppear = PassthroughSubject() - let headerDomainLumaStyle = CurrentValueSubject(.dark) // default dark for placeholder banner // output let domain: CurrentValueSubject let userID: CurrentValueSubject let bannerImageURL: CurrentValueSubject let avatarImageURL: CurrentValueSubject -// let protected: CurrentValueSubject let name: CurrentValueSubject let username: CurrentValueSubject let bioDescription: CurrentValueSubject @@ -42,13 +40,19 @@ class ProfileViewModel: NSObject { let followingCount: CurrentValueSubject let followersCount: CurrentValueSubject -// let friendship: CurrentValueSubject -// let followedBy: CurrentValueSubject -// let muted: CurrentValueSubject -// let blocked: CurrentValueSubject -// -// let suspended = CurrentValueSubject(false) -// + let protected: CurrentValueSubject + // let suspended: CurrentValueSubject + + let relationshipActionOptionSet = CurrentValueSubject(.none) + let isEditing = CurrentValueSubject(false) + let isFollowedBy = CurrentValueSubject(false) + let isMuting = CurrentValueSubject(false) + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) + + let isRelationshipActionButtonHidden = CurrentValueSubject(true) + let isReplyBarButtonItemHidden = CurrentValueSubject(true) + let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context @@ -65,11 +69,14 @@ class ProfileViewModel: NSObject { self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.statusesCount) }) self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) -// self.friendship = CurrentValueSubject(nil) -// self.followedBy = CurrentValueSubject(nil) -// self.muted = CurrentValueSubject(false) -// self.blocked = CurrentValueSubject(false) + self.protected = CurrentValueSubject(mastodonUser?.locked) super.init() + + relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: \.value, on: isRelationshipActionButtonHidden) + .store(in: &disposeBag) // bind active authentication context.authenticationService.activeMastodonAuthentication @@ -84,26 +91,54 @@ class ProfileViewModel: NSObject { self.currentMastodonUser.value = activeMastodonAuthentication.user } .store(in: &disposeBag) - - setup() - } - -} - -extension ProfileViewModel { - - enum Friendship: CustomDebugStringConvertible { - case following - case pending - case none - var debugDescription: String { - switch self { - case .following: return "following" - case .pending: return "pending" - case .none: return "none" + // query relationship + let mastodonUserID = self.mastodonUser.map { $0?.id } + let pendingRetryPublisher = CurrentValueSubject(1) + + Publishers.CombineLatest3( + mastodonUserID.removeDuplicates().eraseToAnyPublisher(), + context.authenticationService.activeMastodonAuthenticationBox.eraseToAnyPublisher(), + pendingRetryPublisher.eraseToAnyPublisher() + ) + .compactMap { mastodonUserID, activeMastodonAuthenticationBox, _ -> (String, AuthenticationService.MastodonAuthenticationBox)? in + guard let mastodonUserID = mastodonUserID, let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return nil } + guard mastodonUserID != activeMastodonAuthenticationBox.userID else { return nil } + return (mastodonUserID, activeMastodonAuthenticationBox) + } + .setFailureType(to: Error.self) // allow failure + .flatMap { mastodonUserID, activeMastodonAuthenticationBox -> AnyPublisher, Error> in + let domain = activeMastodonAuthenticationBox.domain + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch for user %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUserID) + + return self.context.apiService.relationship(domain: domain, accountIDs: [mastodonUserID], authorizationBox: activeMastodonAuthenticationBox) + //.retry(3) + .eraseToAnyPublisher() + } + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update success", ((#file as NSString).lastPathComponent), #line, #function) + + // there are seconds delay after request follow before requested -> following. Query again when needs + guard let relationship = response.value.first else { return } + if relationship.requested == true { + let delay = pendingRetryPublisher.value + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let _ = self else { return } + pendingRetryPublisher.value = min(2 * delay, 60) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) + } } } + .store(in: &disposeBag) + + setup() } } @@ -117,9 +152,11 @@ extension ProfileViewModel { .receive(on: DispatchQueue.main) .sink { [weak self] mastodonUser, currentMastodonUser in guard let self = self else { return } + // Update view model attribute self.update(mastodonUser: mastodonUser) self.update(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + // Setup observer for user if let mastodonUser = mastodonUser { // setup observer self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) @@ -147,6 +184,7 @@ extension ProfileViewModel { self.mastodonUserObserver = nil } + // Setup observer for user if let currentMastodonUser = currentMastodonUser { // setup observer self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) @@ -179,7 +217,6 @@ extension ProfileViewModel { self.userID.value = mastodonUser?.id self.bannerImageURL.value = mastodonUser?.headerImageURL() self.avatarImageURL.value = mastodonUser?.avatarImageURL() -// self.protected.value = twitterUser?.protected self.name.value = mastodonUser?.displayNameWithFallback self.username.value = mastodonUser?.acctWithDomain self.bioDescription.value = mastodonUser?.note @@ -187,11 +224,159 @@ extension ProfileViewModel { self.statusesCount.value = mastodonUser.flatMap { Int(truncating: $0.statusesCount) } self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } + self.protected.value = mastodonUser?.locked } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { - // TODO: + guard let mastodonUser = mastodonUser, + let currentMastodonUser = currentMastodonUser else { + // set relationship + self.relationshipActionOptionSet.value = .none + self.isFollowedBy.value = false + self.isMuting.value = false + self.isBlocking.value = false + self.isBlockedBy.value = false + + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + return + } + + if mastodonUser == currentMastodonUser { + self.relationshipActionOptionSet.value = [.edit] + // set bar button item state + self.isReplyBarButtonItemHidden.value = true + self.isMoreMenuBarButtonItemHidden.value = true + } else { + // set with follow action default + var relationshipActionSet = RelationshipActionOptionSet([.follow]) + + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isFollowing { + relationshipActionSet.insert(.following) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) + + let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isPending { + relationshipActionSet.insert(.pending) + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) + + let isFollowedBy = currentMastodonUser.followingBy.flatMap { $0.contains(mastodonUser) } ?? false + self.isFollowedBy.value = isFollowedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) + + let isMuting = mastodonUser.mutingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isMuting { + relationshipActionSet.insert(.muting) + } + self.isMuting.value = isMuting + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) + + let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlocking { + relationshipActionSet.insert(.blocking) + } + self.isBlocking.value = isBlocking + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) + + let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + self.isBlockedBy.value = isBlockedBy + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) + + self.relationshipActionOptionSet.value = relationshipActionSet + + // set bar button item state + self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy + self.isMoreMenuBarButtonItemHidden.value = false + } } - +} + +extension ProfileViewModel { + + enum RelationshipAction: Int, CaseIterable { + case none // set hide from UI + case follow + case pending + case following + case muting + case blocking + case blocked + case edit + case editing + + var option: RelationshipActionOptionSet { + return RelationshipActionOptionSet(rawValue: 1 << rawValue) + } + } + + // construct option set on the enum for safe iterator + struct RelationshipActionOptionSet: OptionSet { + let rawValue: Int + + static let none = RelationshipAction.none.option + static let follow = RelationshipAction.follow.option + static let pending = RelationshipAction.pending.option + static let following = RelationshipAction.following.option + static let muting = RelationshipAction.muting.option + static let blocking = RelationshipAction.blocking.option + static let blocked = RelationshipAction.blocked.option + static let edit = RelationshipAction.edit.option + static let editing = RelationshipAction.editing.option + + static let editOptions: RelationshipActionOptionSet = [.edit, .editing] + + func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { + let set = subtracting(except) + for action in RelationshipAction.allCases.reversed() where set.contains(action.option) { + return action + } + + return nil + } + + var title: String { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return " " + } + switch highPriorityAction { + case .none: return " " + case .follow: return L10n.Common.Controls.Firendship.follow + case .pending: return L10n.Common.Controls.Firendship.pending + case .following: return L10n.Common.Controls.Firendship.following + case .muting: return L10n.Common.Controls.Firendship.muted + case .blocking: return L10n.Common.Controls.Firendship.blocked + case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .edit: return L10n.Common.Controls.Firendship.editInfo + case .editing: return L10n.Common.Controls.Actions.done + } + } + + var backgroundColor: UIColor { + guard let highPriorityAction = self.highPriorityAction(except: []) else { + assertionFailure() + return Asset.Colors.Button.normal.color + } + switch highPriorityAction { + case .none: return Asset.Colors.Button.normal.color + case .follow: return Asset.Colors.Button.normal.color + case .pending: return Asset.Colors.Button.normal.color + case .following: return Asset.Colors.Button.normal.color + case .muting: return Asset.Colors.Background.alertYellow.color + case .blocking: return Asset.Colors.Background.danger.color + case .blocked: return Asset.Colors.Button.disabled.color + case .edit: return Asset.Colors.Button.normal.color + case .editing: return Asset.Colors.Button.normal.color + } + } + + } } diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift new file mode 100644 index 000000000..ccd17c612 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -0,0 +1,167 @@ +// +// APIService+Block.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleBlock( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { blockQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.blockUpdateRemote( + blockQueryType: blockQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.blockUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return block query update type for remote request + func blockUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.BlockQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.BlockQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isBlocking = (mastodonUser.blockingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isBlocking ? .unblock : .block + mastodonUser.update(isBlocking: !isBlocking, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func blockUpdateRemote( + blockQueryType: Mastodon.API.Account.BlockQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.block( + session: session, + domain: domain, + accountID: mastodonUserID, + blockQueryType: blockQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch blockQueryType { + case .block: + break + case .unblock: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift new file mode 100644 index 000000000..f52aae999 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -0,0 +1,187 @@ +// +// APIService+Follow.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + /// Toggle friendship between target MastodonUser and current MastodonUser + /// + /// Following / Following pending <-> Unfollow + /// + /// - Parameters: + /// - mastodonUser: target MastodonUser + /// - activeMastodonAuthenticationBox: `AuthenticationService.MastodonAuthenticationBox` + /// - Returns: publisher for `Relationship` + func toggleFollow( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { followQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.followUpdateRemote( + followQueryType: followQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.followUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return follow query update type for remote request + func followUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.FollowQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.FollowQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isPending = (mastodonUser.followRequestedBy ?? Set()).contains(_requestMastodonUser) + let isFollowing = (mastodonUser.followingBy ?? Set()).contains(_requestMastodonUser) + + if isFollowing || isPending { + _queryType = .unfollow + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } else { + _queryType = .follow(query: Mastodon.API.Account.FollowQuery()) + if mastodonUser.locked { + mastodonUser.update(isFollowing: false, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: true, by: _requestMastodonUser) + } else { + mastodonUser.update(isFollowing: true, by: _requestMastodonUser) + mastodonUser.update(isFollowRequested: false, by: _requestMastodonUser) + } + } + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func followUpdateRemote( + followQueryType: Mastodon.API.Account.FollowQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.follow( + session: session, + domain: domain, + accountID: mastodonUserID, + followQueryType: followQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + switch followQueryType { + case .follow: + break + case .unfollow: + break + } + } + }) + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift new file mode 100644 index 000000000..2f9303261 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -0,0 +1,167 @@ +// +// APIService+Mute.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func toggleMute( + for mastodonUser: MastodonUser, + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .handleEvents { _ in + impactFeedbackGenerator.prepare() + } receiveOutput: { _ in + impactFeedbackGenerator.impactOccurred() + } receiveCompletion: { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update fail", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + assertionFailure(error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] local relationship update success", ((#file as NSString).lastPathComponent), #line, #function) + break + } + } + .flatMap { muteQueryType, mastodonUserID -> AnyPublisher, Error> in + return self.muteUpdateRemote( + muteQueryType: muteQueryType, + mastodonUserID: mastodonUserID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + } + .receive(on: DispatchQueue.main) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: [Relationship] remote friendship update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + // TODO: handle error + + // rollback + + self.muteUpdateLocal( + mastodonUserObjectID: mastodonUser.objectID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { completion in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) + } receiveValue: { _ in + // do nothing + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) + } + .store(in: &self.disposeBag) + + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) + } + }) + .eraseToAnyPublisher() + } + +} + +extension APIService { + + // update database local and return mute query update type for remote request + func muteUpdateLocal( + mastodonUserObjectID: NSManagedObjectID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher<(Mastodon.API.Account.MuteQueryType, MastodonUser.ID), Error> { + let domain = mastodonAuthenticationBox.domain + let requestMastodonUserID = mastodonAuthenticationBox.userID + + var _targetMastodonUserID: MastodonUser.ID? + var _queryType: Mastodon.API.Account.MuteQueryType? + let managedObjectContext = backgroundManagedObjectContext + + return managedObjectContext.performChanges { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + guard let _requestMastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + + let mastodonUser = managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + _targetMastodonUserID = mastodonUser.id + + let isMuting = (mastodonUser.mutingBy ?? Set()).contains(_requestMastodonUser) + _queryType = isMuting ? .unmute : .mute + mastodonUser.update(isMuting: !isMuting, by: _requestMastodonUser) + } + .tryMap { result in + switch result { + case .success: + guard let targetMastodonUserID = _targetMastodonUserID, + let queryType = _queryType else { + throw APIError.implicit(.badRequest) + } + return (queryType, targetMastodonUserID) + + case .failure(let error): + assertionFailure(error.localizedDescription) + throw error + } + } + .eraseToAnyPublisher() + } + + func muteUpdateRemote( + muteQueryType: Mastodon.API.Account.MuteQueryType, + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Account.mute( + session: session, + domain: domain, + accountID: mastodonUserID, + muteQueryType: muteQueryType, + authorization: authorization + ) + .handleEvents(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + // TODO: handle error + break + case .finished: + // TODO: update relationship + switch muteQueryType { + case .mute: + break + case .unmute: + break + } + } + }) + .eraseToAnyPublisher() + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Relationship.swift b/Mastodon/Service/APIService/APIService+Relationship.swift index 7ad5b4745..b0ef29267 100644 --- a/Mastodon/Service/APIService/APIService+Relationship.swift +++ b/Mastodon/Service/APIService/APIService+Relationship.swift @@ -5,7 +5,7 @@ // Created by MainasuK Cirno on 2021-4-1. // -import Foundation +import UIKit import Combine import CoreData import CoreDataStack @@ -19,47 +19,47 @@ extension APIService { accountIDs: [Mastodon.Entity.Account.ID], authorizationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { - fatalError() -// let authorization = authorizationBox.userAuthorization -// let requestMastodonUserID = authorizationBox.userID -// let query = Mastodon.API.Account.AccountStatuseseQuery( -// maxID: maxID, -// sinceID: sinceID, -// excludeReplies: excludeReplies, -// excludeReblogs: excludeReblogs, -// onlyMedia: onlyMedia, -// limit: limit -// ) -// -// return Mastodon.API.Account.statuses( -// session: session, -// domain: domain, -// accountID: accountID, -// query: query, -// authorization: authorization -// ) -// .flatMap { response -> AnyPublisher, Error> in -// return APIService.Persist.persistStatus( -// managedObjectContext: self.backgroundManagedObjectContext, -// domain: domain, -// query: nil, -// response: response, -// persistType: .user, -// requestMastodonUserID: requestMastodonUserID, -// log: OSLog.api -// ) -// .setFailureType(to: Error.self) -// .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Status]> in -// switch result { -// case .success: -// return response -// case .failure(let error): -// throw error -// } -// } -// .eraseToAnyPublisher() -// } -// .eraseToAnyPublisher() + let authorization = authorizationBox.userAuthorization + let requestMastodonUserID = authorizationBox.userID + let query = Mastodon.API.Account.RelationshipQuery( + ids: accountIDs + ) + + return Mastodon.API.Account.relationships( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, ids: accountIDs) + lookUpMastodonUserRequest.fetchLimit = accountIDs.count + let lookUpMastodonusers = managedObjectContext.safeFetch(lookUpMastodonUserRequest) + + for user in lookUpMastodonusers { + guard let entity = response.value.first(where: { $0.id == user.id }) else { continue } + APIService.CoreData.update(user: user, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index 745f47999..fdac2a2a6 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -111,6 +111,7 @@ extension APIService.CoreData { networkDate: Date ) { guard networkDate > user.updatedAt else { return } + guard entity.id != requestMastodonUser.id else { return } // not update relationship for self user.update(isFollowing: entity.following, by: requestMastodonUser) entity.requested.flatMap { user.update(isFollowRequested: $0, by: requestMastodonUser) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift index ec8bf9d5e..2c0c39b97 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Friendship.swift @@ -67,3 +67,350 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + + public enum FollowQueryType { + case follow(query: FollowQuery) + case unfollow + } + + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + followQueryType: FollowQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch followQueryType { + case .follow(let query): + return follow(session: session, domain: domain, accountID: accountID, query: query, authorization: authorization) + case .unfollow: + return unfollow(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func followEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/follow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Follow + /// + /// Follow the given account. Can also be used to update whether to show reblogs or enable notifications. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func follow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + query: FollowQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: followEndpointURL(domain: domain, accountID: accountID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FollowQuery: Codable, PostQuery { + public let reblogs: Bool? + public let notify: Bool? + + public init(reblogs: Bool? = nil , notify: Bool? = nil) { + self.reblogs = reblogs + self.notify = notify + } + } + +} + +extension Mastodon.API.Account { + + static func unfollowEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unfollow" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unfollow + /// + /// Unfollow the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unfollow( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unfollowEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum BlockQueryType { + case block + case unblock + } + + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + blockQueryType: BlockQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch blockQueryType { + case .block: + return block(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unblock: + return unblock(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func blockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/block" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Block + /// + /// Block the given account. Clients should filter statuses from this account if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func block( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: blockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unblockEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unblock" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unblock + /// + /// Unblock the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unblock( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unblockEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + public enum MuteQueryType { + case mute + case unmute + } + + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + muteQueryType: MuteQueryType, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + switch muteQueryType { + case .mute: + return mute(session: session, domain: domain, accountID: accountID, authorization: authorization) + case .unmute: + return unmute(session: session, domain: domain, accountID: accountID, authorization: authorization) + } + } + +} + +extension Mastodon.API.Account { + + static func mutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/mute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Mute + /// + /// Mute the given account. Clients should filter statuses and notifications from this account, if received (e.g. due to a boost in the Home timeline). + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func mute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: mutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} + +extension Mastodon.API.Account { + + static func unmutekEndpointURL(domain: String, accountID: Mastodon.Entity.Account.ID) -> URL { + let pathComponent = "accounts/" + accountID + "/unmute" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Unmute + /// + /// Unmute the given account. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/1 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - accountID: id for account + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response + public static func unmute( + session: URLSession, + domain: String, + accountID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: unmutekEndpointURL(domain: domain, accountID: accountID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From 3b576badebdda67879e74023b16ffb4c1488d41e Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 18:50:08 +0800 Subject: [PATCH 171/400] feat: add reply entry for profile scene --- .../Diffiable/Section/ComposeStatusSection.swift | 1 + Mastodon/Helper/MastodonField.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../Scene/Compose/ComposeViewModel+Diffable.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 15 ++++++++++++--- .../Scene/Profile/ProfileViewController.swift | 9 +++++++-- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index e9785461a..ebf95a093 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -22,6 +22,7 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { case post + case mention(mastodonUserObjectID: NSManagedObjectID) case reply(repliedToStatusObjectID: NSManagedObjectID) } } diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift index cbe87c09b..e828602e4 100644 --- a/Mastodon/Helper/MastodonField.swift +++ b/Mastodon/Helper/MastodonField.swift @@ -11,7 +11,7 @@ import ActiveLabel enum MastodonField { static func parse(field string: String) -> ParseResult { - let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+))") + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)") let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index c316e993e..3e82cd51a 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -538,7 +538,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)|#([^\\s.]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index d44892565..496ba2845 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -62,7 +62,7 @@ extension ComposeViewModel { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) - case .post: + case .mention, .post: snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52ca4cc88..3b81a931c 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -71,18 +71,27 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind + composeKind: ComposeStatusSection.ComposeKind, + initialComposeContent: String? = nil ) { self.context = context self.composeKind = composeKind switch composeKind { - case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) - case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init + if case let .mention(mastodonUserObjectID) = composeKind { + context.managedObjectContext.performAndWait { + let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser + let initialComposeContent = "@" + mastodonUser.acct + " " + self.composeStatusAttribute.composeContent.value = initialComposeContent + } + } + isCustomEmojiComposing .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 57a398b4b..315a49427 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -429,7 +429,12 @@ extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - // TODO: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let composeViewModel = ComposeViewModel( + context: context, + composeKind: .mention(mastodonUserObjectID: mastodonUser.objectID) + ) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -641,7 +646,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) { - + } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dwingDashboardMeterView: ProfileStatusDashboardMeterView) { From 2f89471c7804e1f3d6c59912d563ed225bddf8a1 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 19:33:29 +0800 Subject: [PATCH 172/400] feat: add remote profile load logic for profile scene --- Mastodon.xcodeproj/project.pbxproj | 4 ++ ...Provider+StatusTableViewCellDelegate.swift | 4 ++ .../StatusProvider/StatusProviderFacade.swift | 62 +++++++++++++++++ .../Profile/RemoteProfileViewModel.swift | 54 +++++++++++++++ .../Scene/Share/View/Content/StatusView.swift | 10 +++ .../TableviewCell/StatusTableViewCell.swift | 13 +++- .../APIService/APIService+Account.swift | 67 ++++++++++++++++++- 7 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 Mastodon/Scene/Profile/RemoteProfileViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2d5d0486b..260366a79 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -266,6 +266,7 @@ DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F932616E28B004B8251 /* APIService+Follow.swift */; }; DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; + DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -618,6 +619,7 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Follow.swift"; sourceTree = ""; }; DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -1524,6 +1526,7 @@ DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */, + DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */, DBB525632612C988002F1F29 /* MeProfileViewModel.swift */, ); path = Profile; @@ -2098,6 +2101,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, + DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index ffaa29b52..f8c99c13f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -24,6 +24,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.coordinateToStatusAuthorProfileScene(for: .primary, provider: self, cell: cell) } + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) + } + } // MARK: - ActionToolbarContainerDelegate diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 19b0fbf7e..fa9ce3adf 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -62,6 +62,68 @@ extension StatusProviderFacade { } } +extension StatusProviderFacade { + + static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { + switch entity.type { + case .hashtag(let text, let userInfo): + break + case .mention(let text, let userInfo): + coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) + case .url(_, _, let url, _): + guard let url = URL(string: url) else { return } + provider.coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) + default: + break + } + } + + private static func coordinateToStatusMentionProfileScene(for target: Target, provider: StatusProvider, cell: UITableViewCell, mention: String) { + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + provider.status(for: cell, indexPath: nil) + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status + case .secondary: return status + } + }() + guard let status = _status else { return } + + // cannot continue without meta + guard let mentionMeta = (status.mentions ?? Set()).first(where: { $0.username == mention }) else { return } + + let userID = mentionMeta.id + + let profileViewModel: ProfileViewModel = { + // check if self + guard userID != activeMastodonAuthenticationBox.userID else { + return MeProfileViewModel(context: provider.context) + } + + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: userID) + let mastodonUser = provider.context.managedObjectContext.safeFetch(request).first + + if let mastodonUser = mastodonUser { + return CachedProfileViewModel(context: provider.context, mastodonUser: mastodonUser) + } else { + return RemoteProfileViewModel(context: provider.context, userID: userID) + } + }() + + DispatchQueue.main.async { + provider.coordinator.present(scene: .profile(viewModel: profileViewModel), from: provider, transition: .show) + } + } + .store(in: &provider.disposeBag) + } +} + extension StatusProviderFacade { static func responseToStatusLikeAction(provider: StatusProvider) { diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift new file mode 100644 index 000000000..c480e6fc9 --- /dev/null +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -0,0 +1,54 @@ +// +// RemoteProfileViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-2. +// + +import os.log +import Foundation +import CoreDataStack +import MastodonSDK + +final class RemoteProfileViewModel: ProfileViewModel { + + convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) { + self.init(context: context, optionalMastodonUser: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + context.apiService.accountInfo( + domain: domain, + userID: userID, + authorization: authorization + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, userID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote user %s fetched", ((#file as NSString).lastPathComponent), #line, #function, userID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = MastodonUser.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = MastodonUser.predicate(domain: domain, id: response.value.id) + guard let mastodonUser = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.mastodonUser.value = mastodonUser + } + .store(in: &disposeBag) + + } + + +} diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index b4105a4d2..fc0fda099 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -17,6 +17,7 @@ protocol StatusViewDelegate: class { func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) } final class StatusView: UIView { @@ -402,6 +403,7 @@ extension StatusView { statusContentWarningContainerStackView.isHidden = true statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + activeTextLabel.delegate = self playerContainerView.delegate = self headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) @@ -475,6 +477,14 @@ extension StatusView { } +// MARK: - ActiveLabelDelegate +extension StatusView: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select entity: %s", ((#file as NSString).lastPathComponent), #line, #function, entity.primaryText) + delegate?.statusView(self, activeLabel: activeLabel, didSelectActiveEntity: entity) + } +} + // MARK: - PlayerContainerViewDelegate extension StatusView: PlayerContainerViewDelegate { func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 9c954e505..e93213fea 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -11,6 +11,7 @@ import AVKit import Combine import CoreData import CoreDataStack +import ActiveLabel protocol StatusTableViewCellDelegate: class { var context: AppContext! { get } @@ -18,18 +19,22 @@ protocol StatusTableViewCellDelegate: class { func parent() -> UIViewController var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { get } - func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) + func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, pollTableView: PollTableView, didSelectRowAt indexPath: IndexPath) } @@ -216,6 +221,10 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, pollVoteButtonPressed: button) } + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + delegate?.statusTableViewCell(self, statusView: statusView, activeLabel: activeLabel, didSelectActiveEntity: entity) + } + } // MARK: - MosaicImageViewDelegate diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index d8ea5cf4f..04908514b 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -10,6 +10,52 @@ import Combine import CommonOSLog import MastodonSDK +extension APIService { + + func accountInfo( + domain: String, + userID: Mastodon.Entity.Account.ID, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.accountInfo( + session: session, + domain: domain, + userID: userID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + let account = response.value + + return self.backgroundManagedObjectContext.performChanges { + let (mastodonUser, isCreated) = APIService.CoreData.createOrMergeMastodonUser( + into: self.backgroundManagedObjectContext, + for: nil, + in: domain, + entity: account, + userCache: nil, + networkDate: response.networkDate, + log: log + ) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} + extension APIService { func accountVerifyCredentials( @@ -33,12 +79,20 @@ extension APIService { entity: account, userCache: nil, networkDate: response.networkDate, - log: log) + log: log + ) let flag = isCreated ? "+" : "-" os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) } .setFailureType(to: Error.self) - .map { _ in return response } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } .eraseToAnyPublisher() } .eraseToAnyPublisher() @@ -72,7 +126,14 @@ extension APIService { os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user [%s](%s)%s verifed", ((#file as NSString).lastPathComponent), #line, #function, flag, mastodonUser.id, mastodonUser.username) } .setFailureType(to: Error.self) - .map { _ in return response } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } .eraseToAnyPublisher() } .eraseToAnyPublisher() From 28cfe961715b16d36259d16bcdf854ea019ecb66 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 2 Apr 2021 19:40:15 +0800 Subject: [PATCH 173/400] chore: rename Toot -> Status --- ...ashtagTimelineViewController+StatusProvider.swift | 8 ++++---- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 4 ++-- .../HashtagTimelineViewModel+LoadLatestState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadMiddleState.swift | 4 ++-- .../HashtagTimelineViewModel+LoadOldestState.swift | 12 ++++++------ .../HashtagTimeline/HashtagTimelineViewModel.swift | 4 ++-- .../APIService/APIService+HashtagTimeline.swift | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index e4092ce0f..7263e6ab8 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -14,11 +14,11 @@ import CoreDataStack // MARK: - StatusProvider extension HashtagTimelineViewController: StatusProvider { - func toot() -> Future { + func status() -> Future { return Future { promise in promise(.success(nil)) } } - func toot(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() @@ -36,7 +36,7 @@ extension HashtagTimelineViewController: StatusProvider { let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.toot)) + promise(.success(timelineIndex?.status)) } default: promise(.success(nil)) @@ -44,7 +44,7 @@ extension HashtagTimelineViewController: StatusProvider { } } - func toot(for cell: UICollectionViewCell) -> Future { + func status(for cell: UICollectionViewCell) -> Future { return Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index e82bb31ae..c831cf215 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -234,7 +234,7 @@ extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { - func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineTootID: String?, timelineIndexobjectID: NSManagedObjectID?) { + func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { guard let upperTimelineIndexObjectID = timelineIndexobjectID else { return } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a41568787..9a6102e09 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -56,13 +56,13 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let snapshot = snapshot as NSDiffableDataSourceSnapshot let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Toot + let status = managedObjectContext.object(with: $0) as! Status let isStatusTextSensitive: Bool = { guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } return true }() - return Item.toot(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index b3cb2cc3b..d8e286195 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadLatestState { case .failure(let error): // TODO: handle error viewModel.isFetchingLatestTimeline.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch statues failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -83,7 +83,7 @@ extension HashtagTimelineViewModel.LoadLatestState { viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) viewModel.hashtagStatusIDList.removeDuplicates() - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index 3c3b01d87..e971659e1 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -76,7 +76,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { switch completion { case .failure(let error): // TODO: handle error - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) stateMachine.enter(Fail.self) case .finished: break @@ -105,7 +105,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { viewModel.needLoadMiddleIndex = nil } - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index f503420a7..d464d3a50 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -66,22 +66,22 @@ extension HashtagTimelineViewModel.LoadOldestState { // viewModel.homeTimelineNavigationBarState.receiveCompletion(completion: completion) switch completion { case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: fetch toots failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log("%{public}s[%{public}ld], %{public}s: fetch statuses failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { response in - let toots = response.value - // enter no more state when no new toots - if toots.isEmpty || (toots.count == 1 && toots[0].id == maxID) { + let statuses = response.value + // enter no more state when no new statuses + if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) } - let newStatusIDList = toots.map { $0.id } + let newStatusIDList = statuses.map { $0.id } viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Toot.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) + let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) viewModel.timelinePredicate.send(newPredicate) } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index 8f2e07874..e7f167f2a 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -24,7 +24,7 @@ final class HashtagTimelineViewModel: NSObject { // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: NSFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -70,7 +70,7 @@ final class HashtagTimelineViewModel: NSObject { self.context = context self.hashTag = hashTag self.fetchedResultsController = { - let fetchRequest = Toot.sortedFetchRequest + let fetchRequest = Status.sortedFetchRequest fetchRequest.returnsObjectsAsFaults = false fetchRequest.fetchBatchSize = 20 let controller = NSFetchedResultsController( diff --git a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift index d3e9d6208..69c2c7486 100644 --- a/Mastodon/Service/APIService/APIService+HashtagTimeline.swift +++ b/Mastodon/Service/APIService/APIService+HashtagTimeline.swift @@ -19,7 +19,7 @@ extension APIService { domain: String, sinceID: Mastodon.Entity.Status.ID? = nil, maxID: Mastodon.Entity.Status.ID? = nil, - limit: Int = onceRequestTootMaxCount, + limit: Int = onceRequestStatusMaxCount, local: Bool? = nil, hashtag: String, authorizationBox: AuthenticationService.MastodonAuthenticationBox From 608e916320b1a0797f8a62ad32451460cda346d1 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 2 Apr 2021 20:45:33 +0800 Subject: [PATCH 174/400] chore: remove extension from MastodonSDK --- Mastodon.xcodeproj/project.pbxproj | 12 +++++++++++ .../MastodonSDK/Mastodon+Entity+Account.swift | 18 +++++++++++++++++ .../MastodonSDK/Mastodon+Entity+History.swift | 20 +++++++++++++++++++ .../MastodonSDK/Mastodon+Entity+Tag.swift | 18 +++++++++++++++++ .../Entity/Mastodon+Entity+Account.swift | 11 +--------- .../Entity/Mastodon+Entity+History.swift | 2 +- .../Entity/Mastodon+Entity+Tag.swift | 11 +++++----- 7 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0bff57bed..a2ab8dd41 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -108,6 +108,9 @@ 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; }; + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; }; + 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; }; 5DF1054125F886D400D6C0D4 /* ViedeoPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */; }; 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */; }; 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */; }; @@ -449,6 +452,9 @@ 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; }; + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViedeoPlaybackService.swift; sourceTree = ""; }; 5DF1054625F8870E00D6C0D4 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayer.swift; sourceTree = ""; }; @@ -1275,6 +1281,9 @@ isa = PBXGroup; children = ( DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */, + 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */, + 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */, + 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, ); path = MastodonSDK; @@ -2048,6 +2057,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -2092,6 +2102,7 @@ DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, + 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, @@ -2118,6 +2129,7 @@ 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, + 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift new file mode 100644 index 000000000..8fd6bd67a --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Account.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Account.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Account: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift new file mode 100644 index 000000000..b116889b8 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+History.swift @@ -0,0 +1,20 @@ +// +// Mastodon+Entity+History.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.History: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(uses) + hasher.combine(accounts) + hasher.combine(day) + } + + public static func == (lhs: Mastodon.Entity.History, rhs: Mastodon.Entity.History) -> Bool { + return lhs.uses == rhs.uses && lhs.uses == rhs.uses && lhs.day == rhs.day + } +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift new file mode 100644 index 000000000..caf819b38 --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Tag.swift @@ -0,0 +1,18 @@ +// +// Mastodon+Entity+Tag.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/2. +// + +import MastodonSDK + +extension Mastodon.Entity.Tag: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + } + + public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { + return lhs.name == rhs.name + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index db1913dc8..13f3c0a71 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -17,16 +17,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/account/) - public class Account: Codable, Hashable { - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - public static func == (lhs: Mastodon.Entity.Account, rhs: Mastodon.Entity.Account) -> Bool { - return lhs.id == rhs.id - } - + public class Account: Codable { public typealias ID = String diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 4e3a66400..9bf1a3a28 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/history/) - public struct History: Codable, Hashable { + public struct History: Codable { /// UNIX timestamp on midnight of the given day public let day: Date public let uses: String diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift index 867ff71a9..e7f095eb3 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Tag.swift @@ -16,15 +16,16 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/tag/) - public struct Tag: Codable, Hashable { - public static func == (lhs: Mastodon.Entity.Tag, rhs: Mastodon.Entity.Tag) -> Bool { - return lhs.name == rhs.name - } - + public struct Tag: Codable { // Base public let name: String public let url: String public let history: [History]? + enum CodingKeys: String, CodingKey { + case name + case url + case history + } } } From 824d214ce74d0e444b8fae8bdf13770a416f303c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 16:42:45 +0800 Subject: [PATCH 175/400] chore: update color asset --- Mastodon/Generated/Assets.swift | 20 +++------- .../Button/disabled.colorset/Contents.json | 24 ++++++++++-- .../Button/inactive.colorset/Contents.json | 38 +++++++++++++++++++ .../Button/normal.colorset/Contents.json | 6 +-- .../backgroundLight.colorset/Contents.json | 20 ---------- .../Colors/brand.blue.colorset/Contents.json | 38 +++++++++++++++++++ .../buttonDefault.colorset/Contents.json | 20 ---------- .../buttonDisabled.colorset/Contents.json | 20 ---------- .../buttonInactive.colorset/Contents.json | 20 ---------- .../Colors/disabled.colorset/Contents.json | 38 +++++++++++++++++++ .../Colors/inactive.colorset/Contents.json | 38 +++++++++++++++++++ .../lightAlertYellow.colorset/Contents.json | 20 ---------- .../lightBackground.colorset/Contents.json | 20 ---------- .../lightBrandBlue.colorset/Contents.json | 20 ---------- .../lightDarkGray.colorset/Contents.json | 20 ---------- .../lightDisabled.colorset/Contents.json | 20 ---------- .../lightInactive.colorset/Contents.json | 20 ---------- .../lightSecondaryText.colorset/Contents.json | 20 ---------- .../lightSuccessGreen.colorset/Contents.json | 20 ---------- .../Colors/lightWhite.colorset/Contents.json | 20 ---------- .../Contents.json | 0 .../system.green.colorset/Contents.json | 20 ---------- .../HomeTimelineNavigationBarTitleView.swift | 2 +- .../MastodonConfirmEmailViewController.swift | 4 +- .../TableViewCell/PickServerCell.swift | 24 ++++++------ .../TableViewCell/PickServerSearchCell.swift | 10 ++--- .../View/PickServerCategoryView.swift | 2 +- .../Scene/Search/SearchViewController.swift | 2 +- .../Content/NavigationBarProgressView.swift | 2 +- .../TimelineLoaderTableViewCell.swift | 2 +- .../View/ToolBar/ActionToolBarContainer.swift | 2 +- 31 files changed, 206 insertions(+), 326 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/{Background/success.colorset => success.green.colorset}/Contents.json (100%) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 0abcc2341..71034c1d8 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -45,7 +45,6 @@ internal enum Asset { internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") - internal static let success = ColorAsset(name: "Colors/Background/success") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") @@ -54,6 +53,7 @@ internal enum Asset { internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") + internal static let inactive = ColorAsset(name: "Colors/Button/inactive") internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { @@ -73,21 +73,11 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } - internal static let backgroundLight = ColorAsset(name: "Colors/backgroundLight") - internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault") - internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled") - internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive") + internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let danger = ColorAsset(name: "Colors/danger") - internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") - internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") - internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") - internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray") - internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled") - internal static let lightInactive = ColorAsset(name: "Colors/lightInactive") - internal static let lightSecondaryText = ColorAsset(name: "Colors/lightSecondaryText") - internal static let lightSuccessGreen = ColorAsset(name: "Colors/lightSuccessGreen") - internal static let lightWhite = ColorAsset(name: "Colors/lightWhite") - internal static let systemGreen = ColorAsset(name: "Colors/system.green") + internal static let disabled = ColorAsset(name: "Colors/disabled") + internal static let inactive = ColorAsset(name: "Colors/inactive") + internal static let successGreen = ColorAsset(name: "Colors/success.green") internal static let systemOrange = ColorAsset(name: "Colors/system.orange") } internal enum Connectivity { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json index bca754614..f2e6f489e 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/disabled.colorset/Contents.json @@ -5,9 +5,27 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "140", - "green" : "130", - "red" : "110" + "blue" : "0.784", + "green" : "0.682", + "red" : "0.608" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json new file mode 100644 index 000000000..9fbab2202 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.549", + "green" : "0.510", + "red" : "0.431" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.365", + "red" : "0.310" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json index cd9b7c5ba..869ed278a 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Button/normal.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "217", - "green" : "144", - "red" : "43" + "blue" : "0xD9", + "green" : "0x90", + "red" : "0x2B" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json deleted file mode 100644 index 0e4687fb4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/backgroundLight.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json new file mode 100644 index 000000000..a85c0e379 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/brand.blue.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD9", + "green" : "0x90", + "red" : "0x2B" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE4", + "green" : "0x9D", + "red" : "0x3A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json deleted file mode 100644 index 2e1ce5f3a..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonDefault.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json deleted file mode 100644 index 78cde95fb..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonDisabled.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json deleted file mode 100644 index 69dc63851..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/buttonInactive.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json new file mode 100644 index 000000000..303021b9f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/disabled.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "200", + "green" : "174", + "red" : "155" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x5D", + "red" : "0x4F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json new file mode 100644 index 000000000..ea5d9760a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/inactive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8C", + "green" : "0x82", + "red" : "0x6E" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x5D", + "red" : "0x4F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json deleted file mode 100644 index 0e29336a8..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightAlertYellow.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "red" : "0.792", - "blue" : "0.016", - "green" : "0.561", - "alpha" : "1.000" - } - } - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json deleted file mode 100644 index 0e4687fb4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBackground.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.910", - "green" : "0.882", - "red" : "0.851" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json deleted file mode 100644 index d853a71aa..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightBrandBlue.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "217", - "green" : "144", - "red" : "43" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json deleted file mode 100644 index e6461f1d3..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDarkGray.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.169", - "green" : "0.137", - "red" : "0.122" - } - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json deleted file mode 100644 index 78cde95fb..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightDisabled.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.784", - "green" : "0.682", - "red" : "0.608" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json deleted file mode 100644 index 69dc63851..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightInactive.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.549", - "green" : "0.510", - "red" : "0.431" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json deleted file mode 100644 index ac36bf1f4..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSecondaryText.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "components" : { - "blue" : "0.263", - "green" : "0.235", - "alpha" : "0.600", - "red" : "0.235" - }, - "color-space" : "srgb" - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json deleted file mode 100644 index 8ef654ce0..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightSuccessGreen.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "colors" : [ - { - "idiom" : "universal", - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "green" : "0.741", - "red" : "0.475", - "blue" : "0.604" - } - } - } - ] -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json deleted file mode 100644 index 5147016be..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/lightWhite.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal", - "color" : { - "components" : { - "red" : "0.996", - "alpha" : "1.000", - "blue" : "0.996", - "green" : "1.000" - }, - "color-space" : "srgb" - } - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/success.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/success.green.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json deleted file mode 100644 index 8716dcb74..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/system.green.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.604", - "green" : "0.741", - "red" : "0.475" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 604c0915d..242715028 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -120,7 +120,7 @@ extension HomeTimelineNavigationBarTitleView { configureButton( title: L10n.Scene.HomeTimeline.NavigationBarState.published, textColor: .white, - backgroundColor: Asset.Colors.Background.success.color + backgroundColor: Asset.Colors.successGreen.color ) button.isHidden = false diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 338be6ab6..9d15c8476 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -40,7 +40,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let openEmailButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal) + button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) button.setTitleColor(.white, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.openEmailApp, for: .normal) button.layer.masksToBounds = true @@ -53,7 +53,7 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc let dontReceiveButton: UIButton = { let button = UIButton(type: .system) button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.boldSystemFont(ofSize: 15)) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.ConfirmEmail.Button.dontReceiveEmail, for: .normal) button.addTarget(self, action: #selector(dontReceiveButtonPressed(_:)), for: UIControl.Event.touchUpInside) return button diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 5ff83cc70..bf2299122 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -27,7 +27,7 @@ class PickServerCell: UITableViewCell { let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.systemBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -35,7 +35,7 @@ class PickServerCell: UITableViewCell { let domainLabel: UILabel = { let label = UILabel() label.font = .preferredFont(forTextStyle: .headline) - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label @@ -44,7 +44,7 @@ class PickServerCell: UITableViewCell { let checkbox: UIImageView = { let imageView = UIImageView() imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - imageView.tintColor = Asset.Colors.lightSecondaryText.color + imageView.tintColor = Asset.Colors.Label.secondary.color imageView.contentMode = .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints = false return imageView @@ -54,7 +54,7 @@ class PickServerCell: UITableViewCell { let label = UILabel() label.font = .preferredFont(forTextStyle: .subheadline) label.numberOfLines = 0 - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false return label @@ -90,7 +90,7 @@ class PickServerCell: UITableViewCell { let button = UIButton(type: .custom) button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) - button.setTitleColor(Asset.Colors.lightBrandBlue.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) button.translatesAutoresizingMaskIntoConstraints = false return button @@ -98,14 +98,14 @@ class PickServerCell: UITableViewCell { let seperator: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() let langValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -115,7 +115,7 @@ class PickServerCell: UITableViewCell { let usersValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -125,7 +125,7 @@ class PickServerCell: UITableViewCell { let categoryValueLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -135,7 +135,7 @@ class PickServerCell: UITableViewCell { let langTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.language label.textAlignment = .center @@ -146,7 +146,7 @@ class PickServerCell: UITableViewCell { let usersTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.users label.textAlignment = .center @@ -157,7 +157,7 @@ class PickServerCell: UITableViewCell { let categoryTitleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightDarkGray.color + label.textColor = Asset.Colors.Label.primary.color label.font = .preferredFont(forTextStyle: .caption2) label.text = L10n.Scene.ServerPicker.Label.category label.textAlignment = .center diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index f35f586a4..b708313ac 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -17,7 +17,7 @@ class PickServerSearchCell: UITableViewCell { private var bgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightWhite.color + view.backgroundColor = Asset.Colors.Background.systemBackground.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.maskedCorners = [ .layerMinXMinYCorner, @@ -30,7 +30,7 @@ class PickServerSearchCell: UITableViewCell { private var textFieldBgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.lightBackground.color.withAlphaComponent(0.6) + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color.withAlphaComponent(0.6) view.translatesAutoresizingMaskIntoConstraints = false view.layer.masksToBounds = true view.layer.cornerRadius = 6 @@ -42,13 +42,13 @@ class PickServerSearchCell: UITableViewCell { let textField = UITextField() textField.translatesAutoresizingMaskIntoConstraints = false textField.font = .preferredFont(forTextStyle: .headline) - textField.tintColor = Asset.Colors.lightDarkGray.color - textField.textColor = Asset.Colors.lightDarkGray.color + textField.tintColor = Asset.Colors.Label.primary.color + textField.textColor = Asset.Colors.Label.primary.color textField.adjustsFontForContentSizeCategory = true textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.ServerPicker.Input.placeholder, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline), - .foregroundColor: Asset.Colors.lightSecondaryText.color.withAlphaComponent(0.6)]) + .foregroundColor: Asset.Colors.Label.secondary.color.withAlphaComponent(0.6)]) textField.clearButtonMode = .whileEditing textField.autocapitalizationType = .none textField.autocorrectionType = .no diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 7ea147e0a..16d5a9fcc 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -48,7 +48,7 @@ extension PickServerCategoryView { addSubview(bgView) addSubview(titleLabel) - bgView.backgroundColor = Asset.Colors.lightWhite.color + bgView.backgroundColor = Asset.Colors.Background.systemBackground.color NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f76f596c0..f6b4e4341 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -19,7 +19,7 @@ final class SearchViewController: UIViewController, NeedsDependency { let searchBar: UISearchBar = { let searchBar = UISearchBar() searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder - searchBar.tintColor = Asset.Colors.buttonDefault.color + searchBar.tintColor = Asset.Colors.brandBlue.color searchBar.translatesAutoresizingMaskIntoConstraints = false let micImage = UIImage(systemName: "mic.fill") searchBar.setImage(micImage, for: .bookmark, state: .normal) diff --git a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift index d011ca897..3cb1d1d9d 100644 --- a/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift +++ b/Mastodon/Scene/Share/View/Content/NavigationBarProgressView.swift @@ -13,7 +13,7 @@ class NavigationBarProgressView: UIView { let sliderView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.buttonDefault.color + view.backgroundColor = Asset.Colors.brandBlue.color view.translatesAutoresizingMaskIntoConstraints = false return view }() diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index fe54380ed..38bf7ef78 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -67,7 +67,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { func stopAnimating() { activityIndicatorView.stopAnimating() self.loadMoreButton.isEnabled = true - self.loadMoreLabel.textColor = Asset.Colors.buttonDefault.color + self.loadMoreLabel.textColor = Asset.Colors.brandBlue.color self.loadMoreLabel.text = "" } diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index daaa607d9..b777207d2 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -163,7 +163,7 @@ extension ActionToolbarContainer { } private func isReblogButtonHighlightStateDidChange(to isHighlight: Bool) { - let tintColor = isHighlight ? Asset.Colors.systemGreen.color : Asset.Colors.Button.actionToolbar.color + let tintColor = isHighlight ? Asset.Colors.successGreen.color : Asset.Colors.Button.actionToolbar.color reblogButton.tintColor = tintColor reblogButton.setTitleColor(tintColor, for: .normal) reblogButton.setTitleColor(tintColor, for: .highlighted) From 9612cc3902b9327ebb930dcdbea3db778cbf4456 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 16:43:08 +0800 Subject: [PATCH 176/400] feat: handle blocking and blocked state for profile --- Localization/README.md | 14 +- Localization/app.json | 6 + Mastodon.xcodeproj/project.pbxproj | 10 +- Mastodon/Coordinator/SceneCoordinator.swift | 2 +- Mastodon/Diffiable/Item/Item.swift | 44 ++++++- .../Section/CategoryPickerSection.swift | 12 +- .../Diffiable/Section/StatusSection.swift | 13 ++ Mastodon/Extension/ActiveLabel.swift | 9 +- Mastodon/Generated/Strings.swift | 10 ++ .../StatusProvider/StatusProviderFacade.swift | 4 +- .../Resources/en.lproj/Localizable.strings | 7 + .../Scene/Profile/ProfileViewController.swift | 69 +++------- .../Timeline/UserTimelineViewController.swift | 25 +++- .../UserTimelineViewModel+State.swift | 66 ++-------- .../Timeline/UserTimelineViewModel.swift | 100 ++++++++------ .../View/Content/TimelineHeaderView.swift | 122 ++++++++++++++++++ .../TimelineHeaderTableViewCell.swift | 42 ++++++ .../Service/APIService/APIService+Block.swift | 5 +- .../APIService/APIService+Follow.swift | 3 +- .../Service/APIService/APIService+Mute.swift | 4 +- 20 files changed, 390 insertions(+), 177 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift diff --git a/Localization/README.md b/Localization/README.md index 1e6975f8b..b6baf1788 100644 --- a/Localization/README.md +++ b/Localization/README.md @@ -5,4 +5,16 @@ Mastodon localization template file ## How to contribute? -TBD \ No newline at end of file +TBD + +## How to maintains + +```zsh +// enter workdir +cd Mastodon +// edit i18n json +open ./Localization/app.json +// update resource +update_localization.sh + +``` \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 9eaba58f4..ce963936f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -86,6 +86,12 @@ "loader": { "load_missing_posts": "Load missing posts", "loading_missing_posts": "Loading missing posts..." + }, + "header": { + "no_status_found": "No Status Found", + "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", + "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", + "suspended_warning": "This account is suspended." } } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 260366a79..25ad03f3a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -244,7 +244,6 @@ DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; - DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; @@ -295,6 +294,8 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -647,6 +648,8 @@ DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -780,6 +783,7 @@ 2D694A7325F9EB4E0038ADDC /* ContentWarningOverlayView.swift */, 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */, + DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, ); path = Content; sourceTree = ""; @@ -982,6 +986,7 @@ 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, + DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; @@ -2022,6 +2027,7 @@ DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, + DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, @@ -2088,6 +2094,7 @@ DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, + DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, @@ -2153,7 +2160,6 @@ DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */, DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, - DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 4b6eed7ba..67d5142c8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -116,7 +116,7 @@ extension SceneCoordinator { if let navigationControllerVisibleViewController = presentingViewController.navigationController?.visibleViewController { switch viewController { case is ProfileViewController: - let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.title, style: .plain, target: nil, action: nil) + let barButtonItem = UIBarButtonItem(title: navigationControllerVisibleViewController.navigationItem.title, style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white navigationControllerVisibleViewController.navigationItem.backBarButtonItem = barButtonItem default: diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index cd07c8836..0a27f1871 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -22,6 +22,8 @@ enum Item { case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) case publicMiddleLoader(statusID: String) case bottomLoader + + case emptyStateHeader(attribute: EmptyStateHeaderAttribute) } protocol StatusContentWarningAttribute { @@ -56,6 +58,30 @@ extension Item { } } } + + class EmptyStateHeaderAttribute: Hashable { + let id = UUID() + let reason: Reason + + enum Reason { + case noStatusFound + case blocking + case blocked + case suspended + } + + init(reason: Reason) { + self.reason = reason + } + + static func == (lhs: Item.EmptyStateHeaderAttribute, rhs: Item.EmptyStateHeaderAttribute) -> Bool { + return lhs.reason == rhs.reason + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } } extension Item: Equatable { @@ -65,12 +91,14 @@ extension Item: Equatable { return objectIDLeft == objectIDRight case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight - case (.bottomLoader, .bottomLoader): - return true - case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): - return upperLeft == upperRight case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): return upperLeft == upperRight + case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): + return upperLeft == upperRight + case (.bottomLoader, .bottomLoader): + return true + case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): + return attributeLeft == attributeRight default: return false } @@ -84,14 +112,16 @@ extension Item: Hashable { hasher.combine(objectID) case .status(let objectID, _): hasher.combine(objectID) - case .publicMiddleLoader(let upper): - hasher.combine(String(describing: Item.publicMiddleLoader.self)) - hasher.combine(upper) case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): hasher.combine(String(describing: Item.homeMiddleLoader.self)) hasher.combine(upper) + case .publicMiddleLoader(let upper): + hasher.combine(String(describing: Item.publicMiddleLoader.self)) + hasher.combine(upper) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) + case .emptyStateHeader(let attribute): + hasher.combine(attribute) } } } diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 2164d9ebc..52443a13d 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -27,16 +27,16 @@ extension CategoryPickerSection { cell.categoryView.titleLabel.text = item.title cell.observe(\.isSelected, options: [.initial, .new]) { cell, _ in if cell.isSelected { - cell.categoryView.bgView.backgroundColor = Asset.Colors.lightBrandBlue.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) + cell.categoryView.bgView.backgroundColor = Asset.Colors.brandBlue.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 1, x: 0, y: 0, blur: 4.0) if case .all = item { - cell.categoryView.titleLabel.textColor = Asset.Colors.lightWhite.color + cell.categoryView.titleLabel.textColor = .white } } else { - cell.categoryView.bgView.backgroundColor = Asset.Colors.lightWhite.color - cell.categoryView.bgView.applyShadow(color: Asset.Colors.lightBrandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) + cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color + cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) if case .all = item { - cell.categoryView.titleLabel.textColor = Asset.Colors.lightBrandBlue.color + cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color } } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5e891e13a..fe720e0f0 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -79,12 +79,17 @@ extension StatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell + case .emptyStateHeader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell + StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) + return cell } } } } extension StatusSection { + static func configure( cell: StatusTableViewCell, dependency: NeedsDependency, @@ -473,6 +478,14 @@ extension StatusSection { snapshot.appendItems(pollItems, toSection: .main) cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) } + + static func configureEmptyStateHeader( + cell: TimelineHeaderTableViewCell, + attribute: Item.EmptyStateHeaderAttribute + ) { + cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage + cell.timelineHeaderView.messageLabel.text = attribute.reason.message + } } extension StatusSection { diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 614735ad1..66452e23e 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -64,11 +64,8 @@ extension ActiveLabel { /// account field func configure(field: String) { activeEntities.removeAll() - if let parseResult = try? MastodonField.parse(field: field) { - text = parseResult.value - activeEntities = parseResult.activeEntities - } else { - text = "" - } + let parseResult = MastodonField.parse(field: field) + text = parseResult.value + activeEntities = parseResult.activeEntities } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 53ef603e2..7dd334d5a 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -172,6 +172,16 @@ internal enum L10n { } } internal enum Timeline { + internal enum Header { + /// You can’t view Artbot’s profile\n until they unblock you. + internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") + /// You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them. + internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") + /// No Status Found + internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") + /// This account is suspended. + internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + } internal enum Loader { /// Loading missing posts... internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index fa9ce3adf..37db1d853 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -66,9 +66,9 @@ extension StatusProviderFacade { static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { switch entity.type { - case .hashtag(let text, let userInfo): + case .hashtag: break - case .mention(let text, let userInfo): + case .mention(let text, _): coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) case .url(_, _, let url, _): guard let url = URL(string: url) else { return } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 4a9b7bd30..0efa7376e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -54,6 +54,13 @@ Please check your internet connection."; "Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile + until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile + until you unblock them. +Your account looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 315a49427..1fe7a908a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -140,63 +140,17 @@ extension ProfileViewController { } .store(in: &disposeBag) - -// Publishers.CombineLatest4( -// viewModel.muted.eraseToAnyPublisher(), -// viewModel.blocked.eraseToAnyPublisher(), -// viewModel.twitterUser.eraseToAnyPublisher(), -// context.authenticationService.activeTwitterAuthenticationBox.eraseToAnyPublisher() -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] muted, blocked, twitterUser, activeTwitterAuthenticationBox in -// guard let self = self else { return } -// guard let twitterUser = twitterUser, -// let activeTwitterAuthenticationBox = activeTwitterAuthenticationBox, -// twitterUser.id != activeTwitterAuthenticationBox.twitterUserID else { -// self.navigationItem.rightBarButtonItems = [] -// return -// } -// -// if #available(iOS 14.0, *) { -// self.moreMenuBarButtonItem.target = nil -// self.moreMenuBarButtonItem.action = nil -// self.moreMenuBarButtonItem.menu = UserProviderFacade.createMenuForUser( -// twitterUser: twitterUser, -// muted: muted, -// blocked: blocked, -// dependency: self -// ) -// } else { -// // no menu supports for early version -// self.moreMenuBarButtonItem.target = self -// self.moreMenuBarButtonItem.action = #selector(ProfileViewController.moreMenuBarButtonItemPressed(_:)) -// } -// -// var rightBarButtonItems: [UIBarButtonItem] = [self.moreMenuBarButtonItem] -// if muted { -// rightBarButtonItems.append(self.unmuteMenuBarButtonItem) -// } -// -// self.navigationItem.rightBarButtonItems = rightBarButtonItems -// } -// .store(in: &disposeBag) - overlayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) -// drawerSidebarTransitionController = DrawerSidebarTransitionController(drawerSidebarTransitionableViewController: self) - let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter()) - viewModel.domain.assign(to: \.value, on: postsUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: postsUserTimelineViewModel.userID).store(in: &disposeBag) - + bind(userTimelineViewModel: postsUserTimelineViewModel) + let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) - viewModel.domain.assign(to: \.value, on: repliesUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: repliesUserTimelineViewModel.userID).store(in: &disposeBag) - + bind(userTimelineViewModel: repliesUserTimelineViewModel) + let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) - viewModel.domain.assign(to: \.value, on: mediaUserTimelineViewModel.domain).store(in: &disposeBag) - viewModel.userID.assign(to: \.value, on: mediaUserTimelineViewModel.userID).store(in: &disposeBag) + bind(userTimelineViewModel: mediaUserTimelineViewModel) profileSegmentedViewController.pagingViewController.viewModel = { let profilePagingViewModel = ProfilePagingViewModel( @@ -275,7 +229,7 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .sink { [weak self] name in guard let self = self else { return } - self.title = name + self.navigationItem.title = name } .store(in: &disposeBag) @@ -425,6 +379,17 @@ extension ProfileViewController { } +extension ProfileViewController { + + private func bind(userTimelineViewModel: UserTimelineViewModel) { + viewModel.domain.assign(to: \.value, on: userTimelineViewModel.domain).store(in: &disposeBag) + viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) + viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) + viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + } + +} + extension ProfileViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 88134f1e1..442f57cce 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -27,6 +27,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency { let tableView = UITableView() tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineHeaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineHeaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear @@ -100,9 +101,29 @@ extension UserTimelineViewController { // MARK: - UITableViewDelegate extension UserTimelineViewController: UITableViewDelegate { - // TODO: cache cell height func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 200 + guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } + + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return 200 + } + } + // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) + + return ceil(frame.height) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + + let key = item.hashValue + let frame = cell.frame + viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..0caa4a20c 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -31,8 +31,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type: return viewModel.userID.value != nil - case is Suspended.Type: - return true default: return false } @@ -48,10 +46,6 @@ extension UserTimelineViewModel.State { return true case is NoMore.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } @@ -116,8 +110,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type, is LoadingMore.Type: return true - case is Suspended.Type: - return true default: return false } @@ -129,8 +121,6 @@ extension UserTimelineViewModel.State { switch stateClass { case is Reloading.Type, is LoadingMore.Type: return true - case is Suspended.Type: - return true default: return false } @@ -146,10 +136,6 @@ extension UserTimelineViewModel.State { return true case is NoMore.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } @@ -188,7 +174,12 @@ extension UserTimelineViewModel.State { ) .receive(on: DispatchQueue.main) .sink { completion in - + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + break + } } receiveValue: { response in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -210,53 +201,22 @@ extension UserTimelineViewModel.State { .store(in: &viewModel.disposeBag) } } - - class NotAuthorized: UserTimelineViewModel.State { - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Reloading.Type: - return true - case is Suspended.Type: - return true - default: - return false - } - } - - } - - class Blocked: UserTimelineViewModel.State { - - override func isValidNextState(_ stateClass: AnyClass) -> Bool { - switch stateClass { - case is Reloading.Type: - return true - case is Suspended.Type: - return true - default: - return false - } - } - - } - - class Suspended: UserTimelineViewModel.State { - - } class NoMore: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Reloading.Type: return true - case is NotAuthorized.Type, is Blocked.Type: - return true - case is Suspended.Type: - return true default: return false } } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + + // trigger data source update + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index a550dc829..2276db5fe 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -24,6 +24,10 @@ class UserTimelineViewModel: NSObject { let userID: CurrentValueSubject let queryFilter: CurrentValueSubject let statusFetchedResultsController: StatusFetchedResultsController + var cellFrameCache = NSCache() + + let isBlocking = CurrentValueSubject(false) + let isBlockedBy = CurrentValueSubject(false) // output var diffableDataSource: UITableViewDiffableDataSource? @@ -34,9 +38,6 @@ class UserTimelineViewModel: NSObject { State.Fail(viewModel: self), State.Idle(viewModel: self), State.LoadingMore(viewModel: self), - State.NotAuthorized(viewModel: self), - State.Blocked(viewModel: self), - State.Suspended(viewModel: self), State.NoMore(viewModel: self), ]) stateMachine.enter(State.Initial.self) @@ -59,46 +60,64 @@ class UserTimelineViewModel: NSObject { .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - - statusFetchedResultsController.objectIDs - .receive(on: DispatchQueue.main) - .sink { [weak self] objectIDs in - guard let self = self else { return } - guard let diffableDataSource = self.diffableDataSource else { return } - - // var isPermissionDenied = false - - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] - let oldSnapshot = diffableDataSource.snapshot() - for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } - oldSnapshotAttributeDict[objectID] = attribute - } - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - - var items: [Item] = [] - for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - items.append(.status(objectID: objectID, attribute: attribute)) - } - snapshot.appendItems(items, toSection: .main) - - if let currentState = self.stateMachine.currentState { - switch currentState { - case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - // TODO: handle other states - default: - break - } - } - + Publishers.CombineLatest3( + statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), + isBlocking.eraseToAnyPublisher(), + isBlockedBy.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] objectIDs, isBlocking, isBlockedBy in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { // not animate when empty items fix loader first appear layout issue diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) } - .store(in: &disposeBag) + + guard !isBlocking else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocking))], toSection: .main) + return + } + + guard !isBlockedBy else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .blocked))], toSection: .main) + return + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + // TODO: handle other states + default: + break + } + } + + + } + .store(in: &disposeBag) } deinit { @@ -125,3 +144,4 @@ extension UserTimelineViewModel { } } + diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift new file mode 100644 index 000000000..e253b3ca7 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -0,0 +1,122 @@ +// +// TimelineHeaderView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +final class TimelineHeaderView: UIView { + + let iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + return imageView + }() + let messageLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17) + label.textAlignment = .center + label.textColor = Asset.Colors.Label.secondary.color + label.text = "info" + label.numberOfLines = 0 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderView { + + private func _init() { + backgroundColor = .clear + + let topPaddingView = UIView() + topPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(topPaddingView) + NSLayoutConstraint.activate([ + topPaddingView.topAnchor.constraint(equalTo: topAnchor), + topPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + topPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.alignment = .center + containerStackView.distribution = .fill + containerStackView.spacing = 16 + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topPaddingView.bottomAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + containerStackView.addArrangedSubview(iconImageView) + containerStackView.addArrangedSubview(messageLabel) + + let bottomPaddingView = UIView() + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(bottomPaddingView) + NSLayoutConstraint.activate([ + bottomPaddingView.topAnchor.constraint(equalTo: containerStackView.bottomAnchor), + bottomPaddingView.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomPaddingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + NSLayoutConstraint.activate([ + topPaddingView.heightAnchor.constraint(equalToConstant: 100).priority(.defaultHigh), + bottomPaddingView.heightAnchor.constraint(equalTo: topPaddingView.heightAnchor, multiplier: 1.0), + ]) + } + +} + +extension Item.EmptyStateHeaderAttribute.Reason { + var iconImage: UIImage? { + switch self { + case .noStatusFound, .blocking, .blocked, .suspended: + return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! + } + } + + var message: String { + switch self { + case .noStatusFound: + return L10n.Common.Controls.Timeline.Header.noStatusFound + case .blocking: + return L10n.Common.Controls.Timeline.Header.blockingWarning + case .blocked: + return L10n.Common.Controls.Timeline.Header.blockedWarning + case .suspended: + return L10n.Common.Controls.Timeline.Header.suspendedWarning + } + } +} + +#if DEBUG && canImport(SwiftUI) +import SwiftUI + +struct TimelineHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { + let headerView = TimelineHeaderView() + headerView.iconImageView.image = Item.EmptyStateHeaderAttribute.Reason.blocking.iconImage + headerView.messageLabel.text = Item.EmptyStateHeaderAttribute.Reason.blocking.message + return headerView + } + .previewLayout(.fixed(width: 375, height: 400)) + } + } +} +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift new file mode 100644 index 000000000..ba1b6b103 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineHeaderTableViewCell.swift @@ -0,0 +1,42 @@ +// +// TimelineHeaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit + +final class TimelineHeaderTableViewCell: UITableViewCell { + + let timelineHeaderView = TimelineHeaderView() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension TimelineHeaderTableViewCell { + + private func _init() { + selectionStyle = .none + backgroundColor = .clear + + timelineHeaderView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(timelineHeaderView) + NSLayoutConstraint.activate([ + timelineHeaderView.topAnchor.constraint(equalTo: contentView.topAnchor), + timelineHeaderView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + timelineHeaderView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + timelineHeaderView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Block.swift b/Mastodon/Service/APIService/APIService+Block.swift index ccd17c612..124b65155 100644 --- a/Mastodon/Service/APIService/APIService+Block.swift +++ b/Mastodon/Service/APIService/APIService+Block.swift @@ -145,11 +145,12 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error - break + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] block update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: // TODO: update relationship switch blockQueryType { diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index f52aae999..f2c57db57 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -167,10 +167,11 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) break case .finished: switch followQueryType { diff --git a/Mastodon/Service/APIService/APIService+Mute.swift b/Mastodon/Service/APIService/APIService+Mute.swift index 2f9303261..9d992ab6a 100644 --- a/Mastodon/Service/APIService/APIService+Mute.swift +++ b/Mastodon/Service/APIService/APIService+Mute.swift @@ -145,11 +145,11 @@ extension APIService { authorization: authorization ) .handleEvents(receiveCompletion: { [weak self] completion in - guard let self = self else { return } + guard let _ = self else { return } switch completion { case .failure(let error): // TODO: handle error - break + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] Mute update fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) case .finished: // TODO: update relationship switch muteQueryType { From 021d6036cd5c632c89df2a4f92d0d442fb7f8986 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:12:25 +0800 Subject: [PATCH 177/400] chore: update dark mode color for background. Make blocking high priority then blocked --- .../Contents.json | 12 +++--- .../Contents.json | 6 +-- .../system.background.colorset/Contents.json | 10 ++--- .../Contents.json | 6 +-- .../Label/secondary.colorset/Contents.json | 6 +-- .../ProfileRelationshipActionButton.swift | 8 +++- .../Scene/Profile/ProfileViewController.swift | 37 ------------------- Mastodon/Scene/Profile/ProfileViewModel.swift | 8 ++-- 8 files changed, 31 insertions(+), 62 deletions(-) diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index abe46b9aa..55f84c267 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "255", - "green" : "255", - "red" : "255" + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xFE" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x37", - "green" : "0x2D", - "red" : "0x29" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 91dac809a..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.216", - "green" : "0.176", - "red" : "0.161" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index d8f32572f..55f84c267 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", + "blue" : "0xFE", "green" : "0xFF", - "red" : "0xFF" + "red" : "0xFE" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index d47050048..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json index 8953c8fb0..70b1446d0 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/secondary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "0.600", - "blue" : "67", - "green" : "60", - "red" : "60" + "blue" : "0x43", + "green" : "0x3C", + "red" : "0x3C" } }, "idiom" : "universal" diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index b098c1ec1..0f6a804b5 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -34,7 +34,13 @@ extension ProfileRelationshipActionButton { setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled) + + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { + isEnabled = false + } else { + isEnabled = true + } } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 1fe7a908a..59cf4809f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -419,43 +419,6 @@ extension ProfileViewController { // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) // coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) // } -// -// @objc private func unmuteBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// guard let twitterUser = viewModel.twitterUser.value else { -// assertionFailure() -// return -// } -// -// UserProviderFacade.toggleMuteUser( -// context: context, -// twitterUser: twitterUser, -// muted: viewModel.muted.value -// ) -// .sink { _ in -// // do nothing -// } receiveValue: { _ in -// // do nothing -// } -// .store(in: &disposeBag) -// } -// -// @objc private func moreMenuBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// guard let twitterUser = viewModel.twitterUser.value else { -// assertionFailure() -// return -// } -// -// let moreMenuAlertController = UserProviderFacade.createMoreMenuAlertControllerForUser( -// twitterUser: twitterUser, -// muted: viewModel.muted.value, -// blocked: viewModel.blocked.value, -// sender: sender, -// dependency: self -// ) -// present(moreMenuAlertController, animated: true, completion: nil) -// } } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 5df8952b8..057e18030 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -307,8 +307,8 @@ extension ProfileViewModel { case pending case following case muting - case blocking case blocked + case blocking case edit case editing @@ -326,8 +326,8 @@ extension ProfileViewModel { static let pending = RelationshipAction.pending.option static let following = RelationshipAction.following.option static let muting = RelationshipAction.muting.option - static let blocking = RelationshipAction.blocking.option static let blocked = RelationshipAction.blocked.option + static let blocking = RelationshipAction.blocking.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -353,8 +353,8 @@ extension ProfileViewModel { case .pending: return L10n.Common.Controls.Firendship.pending case .following: return L10n.Common.Controls.Firendship.following case .muting: return L10n.Common.Controls.Firendship.muted - case .blocking: return L10n.Common.Controls.Firendship.blocked case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user + case .blocking: return L10n.Common.Controls.Firendship.blocked case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done } @@ -371,8 +371,8 @@ extension ProfileViewModel { case .pending: return Asset.Colors.Button.normal.color case .following: return Asset.Colors.Button.normal.color case .muting: return Asset.Colors.Background.alertYellow.color - case .blocking: return Asset.Colors.Background.danger.color case .blocked: return Asset.Colors.Button.disabled.color + case .blocking: return Asset.Colors.Background.danger.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color } From e4199df42cf40281b6ed97f61cc2531c31cd382b Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:18:06 +0800 Subject: [PATCH 178/400] feat: set background color for banner image view --- .../mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist | 2 +- Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..c1f4dcf1f 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 12 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index a6b1f275c..bf292ac45 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -32,6 +32,7 @@ final class ProfileHeaderView: UIView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor imageView.layer.masksToBounds = true // #if DEBUG // imageView.image = .placeholder(color: .red) From 0822b222fc0cdf03d78b05791280bcc28336230c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:48:20 +0800 Subject: [PATCH 179/400] fix: debug running may assert fail issue --- Mastodon/Extension/UINavigationController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Mastodon/Extension/UINavigationController.swift b/Mastodon/Extension/UINavigationController.swift index 54583e50a..9a9c44ab3 100644 --- a/Mastodon/Extension/UINavigationController.swift +++ b/Mastodon/Extension/UINavigationController.swift @@ -11,7 +11,6 @@ import UIKit // SeeAlso: `AdaptiveStatusBarStyleNavigationController` extension UINavigationController { open override var childForStatusBarStyle: UIViewController? { - assertionFailure("Won't enter here") return visibleViewController } } From 1d6345b12b001fde611e89518456394bb0f546ee Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 6 Apr 2021 17:52:37 +0800 Subject: [PATCH 180/400] fix: text checker not learn reply post mention word issue --- Mastodon/Scene/Compose/ComposeViewModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 3b81a931c..01d015682 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -71,8 +71,7 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind, - initialComposeContent: String? = nil + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind @@ -87,8 +86,9 @@ final class ComposeViewModel { if case let .mention(mastodonUserObjectID) = composeKind { context.managedObjectContext.performAndWait { let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser - let initialComposeContent = "@" + mastodonUser.acct + " " - self.composeStatusAttribute.composeContent.value = initialComposeContent + let initialComposeContent = "@" + mastodonUser.acct + UITextChecker.learnWord(initialComposeContent) + self.composeStatusAttribute.composeContent.value = initialComposeContent + " " } } From 6e10efc490a30b5dd9420727b340403eb6c4261b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 2 Apr 2021 16:24:00 +0800 Subject: [PATCH 181/400] feature:searching page feature: searching Page --- Localization/app.json | 11 +- Mastodon.xcodeproj/project.pbxproj | 32 ++++- .../Diffiable/Item/SearchResultItem.swift | 39 ++++++ .../Section/SearchResultSection.swift | 32 +++++ Mastodon/Generated/Strings.swift | 16 ++- .../Resources/en.lproj/Localizable.strings | 7 +- ...earchRecommendTagsCollectionViewCell.swift | 9 +- ...ft => SearchViewController+Recomend.swift} | 2 +- .../SearchViewController+Searching.swift | 67 ++++++++++ .../Scene/Search/SearchViewController.swift | 32 +++++ Mastodon/Scene/Search/SearchViewModel.swift | 39 ++++++ .../SearchingTableViewCell.swift | 120 ++++++++++++++++++ .../SearchRecommendCollectionHeader.swift | 2 +- .../Entity/Mastodon+Entity+SearchResult.swift | 6 +- 14 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 Mastodon/Diffiable/Item/SearchResultItem.swift create mode 100644 Mastodon/Diffiable/Section/SearchResultSection.swift rename Mastodon/Scene/Search/{SearchViewController+RecomendView.swift => SearchViewController+Recomend.swift} (99%) create mode 100644 Mastodon/Scene/Search/SearchViewController+Searching.swift create mode 100644 Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift diff --git a/Localization/app.json b/Localization/app.json index 6d96fd5bd..3ee1c8d54 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -265,7 +265,7 @@ "cancel": "Cancel" }, "recommend": { - "buttonText": "See All", + "button_text": "See All", "hash_tag": { "title": "Trending in your timeline", "description": "Hashtags that are getting quite a bit of attention among people you follow", @@ -276,6 +276,15 @@ "description": "Except for Sam, you will not like his account.", "follow": "Follow" } + }, + "searching": { + "segment": { + "all": "All", + "people": "People", + "hashtags": "Hashtags" + }, + "recent_search": "Recent searches", + "clear": "clear" } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index a2ab8dd41..9e4a97545 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; + 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; + 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -30,7 +32,7 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; - 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+Recomend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; @@ -104,6 +106,8 @@ 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */; }; 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */; }; 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; + 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; + 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; @@ -366,6 +370,8 @@ 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; + 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -374,7 +380,7 @@ 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+RecomendView.swift"; sourceTree = ""; }; + 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recomend.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; @@ -445,6 +451,8 @@ 2DF75BA625D10E1000694EC8 /* APIService+Favorite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Favorite.swift"; sourceTree = ""; }; 2DF75BB825D1474100694EC8 /* ManagedObjectObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectObserver.swift; sourceTree = ""; }; 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; + 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; }; + 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = ""; }; 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = ""; }; 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = ""; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -943,6 +951,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, ); @@ -991,6 +1000,7 @@ isa = PBXGroup; children = ( 2D7631B225C159F700929FB9 /* Item.swift */, + 2D198642261BF09500F0B013 /* SearchResultItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, @@ -1025,6 +1035,14 @@ path = Stack; sourceTree = ""; }; + 2DFAD5212616F8E300F9EE7C /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -1508,9 +1526,11 @@ DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( + 2DFAD5212616F8E300F9EE7C /* TableViewCell */, 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, - 2D34D9CA261489930081BFC0 /* SearchViewController+RecomendView.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */, + 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); @@ -2034,6 +2054,7 @@ DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, + 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, @@ -2063,6 +2084,7 @@ DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, + 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */, 2D42FF8525C8224F004A627A /* HitTestExpandedButton.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, @@ -2106,6 +2128,7 @@ DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, + 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -2180,7 +2203,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, - 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+Recomend.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, @@ -2235,6 +2258,7 @@ DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, DB87D4512609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift new file mode 100644 index 000000000..a0b5fe253 --- /dev/null +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -0,0 +1,39 @@ +// +// SearchResultItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import MastodonSDK + +enum SearchResultItem { + case hashTag(tag: Mastodon.Entity.Tag) + + case account(account: Mastodon.Entity.Account) +} + +extension SearchResultItem: Equatable { + static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { + switch (lhs, rhs) { + case (.hashTag(let tagLeft), .hashTag(let tagRight)): + return tagLeft == tagRight + case (.account(let accountLeft), account(let accountRight)): + return accountLeft == accountRight + default: + return false + } + } +} + +extension SearchResultItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .account(let account): + hasher.combine(account) + case .hashTag(let tag): + hasher.combine(tag) + } + } +} diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift new file mode 100644 index 000000000..9c481a53a --- /dev/null +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -0,0 +1,32 @@ +// +// SearchResultSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import MastodonSDK +import UIKit + +enum SearchResultSection: Equatable, Hashable { + case account + case hashTag +} + +extension SearchResultSection { + static func tableViewDiffableDataSource( + for tableView: UITableView + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + switch result { + case .account(let account): + cell.config(with: account) + case .hashTag(let tag): + cell.config(with: tag) + } + return cell + } + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 0ce6bf212..05386714e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -408,7 +408,7 @@ internal enum L10n { internal enum Search { internal enum Recommend { /// See All - internal static let buttontext = L10n.tr("Localizable", "Scene.Search.Recommend.Buttontext") + internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText") internal enum Accounts { /// Except for Sam, you will not like his account. internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") @@ -434,6 +434,20 @@ internal enum L10n { /// Search hashtags and users internal static let placeholder = L10n.tr("Localizable", "Scene.Search.Searchbar.Placeholder") } + internal enum Searching { + /// clear + internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear") + /// Recent searches + internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch") + internal enum Segment { + /// All + internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All") + /// Hashtags + internal static let hashtags = L10n.tr("Localizable", "Scene.Search.Searching.Segment.Hashtags") + /// People + internal static let people = L10n.tr("Localizable", "Scene.Search.Searching.Segment.People") + } + } } internal enum ServerPicker { /// Pick a Server,\nany server. diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index f0ac3d44b..662491b2e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -132,12 +132,17 @@ tap the link to confirm your account."; "Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; -"Scene.Search.Recommend.Buttontext" = "See All"; +"Scene.Search.Recommend.ButtonText" = "See All"; "Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; "Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; "Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; "Scene.Search.Searchbar.Cancel" = "Cancel"; "Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; +"Scene.Search.Searching.Clear" = "clear"; +"Scene.Search.Searching.RecentSearch" = "Recent searches"; +"Scene.Search.Searching.Segment.All" = "All"; +"Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; +"Scene.Search.Searching.Segment.People" = "People"; "Scene.ServerPicker.Button.Category.All" = "All"; "Scene.ServerPicker.Button.SeeLess" = "See Less"; "Scene.ServerPicker.Button.SeeMore" = "See More"; diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 685d214e6..7167658c0 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -82,14 +82,7 @@ extension SearchRecommendTagsCollectionViewCell { peopleLabel.text = "" return } - var recentHistory = [Mastodon.Entity.History]() - for history in historys { - if Int(history.uses) == 0 { - break - } else { - recentHistory.append(history) - } - } + let recentHistory = historys[0...2] let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) peopleLabel.text = string diff --git a/Mastodon/Scene/Search/SearchViewController+RecomendView.swift b/Mastodon/Scene/Search/SearchViewController+Recomend.swift similarity index 99% rename from Mastodon/Scene/Search/SearchViewController+RecomendView.swift rename to Mastodon/Scene/Search/SearchViewController+Recomend.swift index ca373b6b5..ff6ba7d17 100644 --- a/Mastodon/Scene/Search/SearchViewController+RecomendView.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recomend.swift @@ -1,5 +1,5 @@ // -// SearchViewController+RecomendView.swift +// SearchViewController+Recomend.swift // Mastodon // // Created by sxiaojian on 2021/3/31. diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift new file mode 100644 index 000000000..b46714832 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -0,0 +1,67 @@ +// +// SearchViewController+Searching.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/2. +// + +import Foundation +import UIKit + +extension SearchViewController { + func setupSearchingTableView() { + searchingTableView.delegate = self + searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) + view.addSubview(searchingTableView) + searchingTableView.constrain([ + searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + ]) + + viewModel.isSearching + .receive(on: DispatchQueue.main) + .sink {[weak self] isSearching in + self?.searchingTableView.isHidden = !isSearching + if !isSearching { + self?.searchResultDiffableDataSource = nil + } + } + .store(in: &disposeBag) + + viewModel.searchResult + .receive(on: DispatchQueue.main) + .sink { [weak self] searchResult in + guard let self = self else { return } + let dataSource = SearchResultSection.tableViewDiffableDataSource(for: self.searchingTableView) + var snapshot = NSDiffableDataSourceSnapshot() + if let accounts = searchResult?.accounts { + snapshot.appendSections([.account]) + let items = accounts.compactMap { SearchResultItem.account(account: $0) } + snapshot.appendItems(items, toSection: .account) + } + if let tags = searchResult?.hashtags { + snapshot.appendSections([.hashTag]) + let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } + snapshot.appendItems(items, toSection: .hashTag) + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.searchResultDiffableDataSource = dataSource + } + .store(in: &disposeBag) + } +} + +// MARK: - UITableViewDelegate + +extension SearchViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + 66 + } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 66 + } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 856407f33..a0a25fef6 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -24,9 +24,12 @@ final class SearchViewController: UIViewController, NeedsDependency { let micImage = UIImage(systemName: "mic.fill") searchBar.setImage(micImage, for: .bookmark, state: .normal) searchBar.showsBookmarkButton = true + searchBar.showsScopeBar = false + searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people,L10n.Scene.Search.Searching.Segment.hashtags] return searchBar }() + // recommend let scrollView: UIScrollView = { let scrollView = UIScrollView() scrollView.showsVerticalScrollIndicator = false @@ -71,6 +74,16 @@ final class SearchViewController: UIViewController, NeedsDependency { view.translatesAutoresizingMaskIntoConstraints = false return view }() + + // searching + let searchingTableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .singleLine + tableView.backgroundColor = .white + return tableView + }() + var searchResultDiffableDataSource: UITableViewDiffableDataSource? } extension SearchViewController { @@ -83,6 +96,7 @@ extension SearchViewController { setupScrollView() setupHashTagCollectionView() setupAccountsCollectionView() + setupSearchingTableView() } func setupScrollView() { @@ -109,22 +123,40 @@ extension SearchViewController { extension SearchViewController: UISearchBarDelegate { func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(true, animated: true) + searchBar.showsScopeBar = true + viewModel.isSearching.value = true } func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(false, animated: true) + searchBar.showsScopeBar = false + viewModel.isSearching.value = true } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.setShowsCancelButton(false, animated: true) + searchBar.showsScopeBar = false searchBar.text = "" searchBar.resignFirstResponder() + viewModel.isSearching.value = false } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { viewModel.searchText.send(searchText) } + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + switch selectedScope { + case 0: + viewModel.searchScope.value = "" + case 1: + viewModel.searchScope.value = "accounts" + case 2: + viewModel.searchScope.value = "hashtags" + default: + break + } + } func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 40b22c880..03f689a1b 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -19,12 +19,51 @@ final class SearchViewModel { // output let searchText = CurrentValueSubject("") + let searchScope = CurrentValueSubject("") + + let isSearching = CurrentValueSubject(false) + + let searchResult = CurrentValueSubject(nil) var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [Mastodon.Entity.Account]() init(context: AppContext) { self.context = context + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + Publishers.CombineLatest( + searchText + .filter { !$0.isEmpty } + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), + searchScope) + .flatMap { (text, scope) -> AnyPublisher, Error> in + let query = Mastodon.API.Search.Query(accountID: nil, + maxID: nil, + minID: nil, + type: scope, + excludeUnreviewed: nil, + q: text, + resolve: nil, + limit: nil, + offset: nil, + following: nil) + return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + } + .sink { _ in + } receiveValue: { [weak self] result in + self?.searchResult.value = result.value + } + .store(in: &disposeBag) + + isSearching + .sink { [weak self] isSearching in + if !isSearching { + self?.searchResult.value == nil + } + } + .store(in: &disposeBag) } func requestRecommendHashTags() -> Future { diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift new file mode 100644 index 000000000..ceb35678a --- /dev/null +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -0,0 +1,120 @@ +// +// SearchingTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/2. +// + +import Foundation +import UIKit +import MastodonSDK + +final class SearchingTableViewCell: UITableViewCell { + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = .black + return imageView + }() + + let _titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.buttonDefault.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let _subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SearchingTableViewCell { + private func configure() { + self.selectionStyle = .none + contentView.addSubview(_imageView) + _imageView.pin(toSize: CGSize(width: 42, height: 42)) + _imageView.constrain([ + _imageView.constraint(.leading, toView: contentView, constant: 21), + _imageView.constraint(.centerY, toView: contentView) + ]) + + contentView.addSubview(_titleLabel) + _titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0) + + contentView.addSubview(_subTitleLabel) + _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0) + } + + func config(with account:Mastodon.Entity.Account) { + self._imageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + self._titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + self._subTitleLabel.text = account.acct + } + + func config(with tag:Mastodon.Entity.Tag) { + let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) + self._imageView.image = image + self._titleLabel.text = "# " + tag.name + guard let historys = tag.history else { + self._subTitleLabel.text = "" + return + } + let recentHistory = historys[0...2] + let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + self._subTitleLabel.text = string + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SearchingTableViewCell_Previews: PreviewProvider { + static var controls: some View { + Group { + UIViewPreview { + let cell = SearchingTableViewCell() + cell.backgroundColor = .white + cell._imageView.image = UIImage(systemName: "number.circle.fill") + cell._titleLabel.text = "Electronic Frontier Foundation" + cell._subTitleLabel.text = "@eff@mastodon.social" + return cell + } + .previewLayout(.fixed(width: 228, height: 130)) + } + } + + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + .background(Color.gray) + } +} + +#endif diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index 00efecd85..193da2c47 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -28,7 +28,7 @@ class SearchRecommendCollectionHeader: UIView { let seeAllButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) - button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttonText, for: .normal) return button }() diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f06f1a54e..f10339664 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,9 +8,9 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { - let accounts: [Mastodon.Entity.Account] - let statuses: [Mastodon.Entity.Status] - let hashtags: [Mastodon.Entity.Tag] + public let accounts: [Mastodon.Entity.Account] + public let statuses: [Mastodon.Entity.Status] + public let hashtags: [Mastodon.Entity.Tag] } } From 90803fc5441175501e6197f512e1b247af2e3813 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 6 Apr 2021 15:25:04 +0800 Subject: [PATCH 182/400] chore: add bottom loader --- Mastodon.xcodeproj/project.pbxproj | 20 ++- .../Diffiable/Item/SearchResultItem.swift | 8 +- ...on.swift => RecommendHashTagSection.swift} | 8 +- .../Section/SearchResultSection.swift | 11 +- Mastodon/Extension/Array.swift | 20 +++ .../SearchViewController+Recomend.swift | 32 ---- .../SearchViewController+Searching.swift | 32 +--- .../Scene/Search/SearchViewController.swift | 34 ++++- .../SearchViewModel+LoadOldestState.swift | 141 ++++++++++++++++++ Mastodon/Scene/Search/SearchViewModel.swift | 79 +++++++++- .../TableViewCell/SearchBottomLoader.swift | 47 ++++++ .../SearchingTableViewCell.swift | 26 ++-- .../MastodonSDK/API/Mastodon+API+Search.swift | 20 ++- .../Entity/Mastodon+Entity+SearchResult.swift | 6 + 14 files changed, 392 insertions(+), 92 deletions(-) rename Mastodon/Diffiable/Section/{RecomendHashTagSection.swift => RecommendHashTagSection.swift} (74%) create mode 100644 Mastodon/Extension/Array.swift create mode 100644 Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift create mode 100644 Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9e4a97545..165b9af31 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -20,10 +20,13 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; + 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; + 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; }; + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -97,7 +100,7 @@ 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; - 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */; }; + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */; }; 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF123A625C3B0210020F248 /* ActiveLabel.swift */; }; @@ -368,10 +371,13 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; + 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; + 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = ""; }; + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -442,7 +448,7 @@ 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; - 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecomendHashTagSection.swift; sourceTree = ""; }; + 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountSection.swift; sourceTree = ""; }; 2DF123A625C3B0210020F248 /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveLabel.swift; sourceTree = ""; }; @@ -949,7 +955,7 @@ DB4481C525EE2ADA00BEFB67 /* PollSection.swift */, DB1FD44325F26CCC004CFCFC /* PickServerSection.swift */, DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, - 2DE0FAC02615F04D00CDF649 /* RecomendHashTagSection.swift */, + 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, @@ -1039,6 +1045,7 @@ isa = PBXGroup; children = ( 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, + 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1470,6 +1477,7 @@ 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, + 2D0B7A0A261D5A5600B44727 /* Array.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, @@ -1532,6 +1540,7 @@ 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */, 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, + 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, ); path = Search; @@ -2097,6 +2106,7 @@ 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, + 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, 2D69D00A25CAA00300C3A1B2 /* APIService+CoreData+Status.swift in Sources */, @@ -2135,7 +2145,7 @@ DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, - 2DE0FAC12615F04D00CDF649 /* RecomendHashTagSection.swift in Sources */, + 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -2179,6 +2189,7 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, + 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, @@ -2245,6 +2256,7 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index a0b5fe253..1156a05fa 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -12,6 +12,8 @@ enum SearchResultItem { case hashTag(tag: Mastodon.Entity.Tag) case account(account: Mastodon.Entity.Account) + + case bottomLoader } extension SearchResultItem: Equatable { @@ -19,8 +21,10 @@ extension SearchResultItem: Equatable { switch (lhs, rhs) { case (.hashTag(let tagLeft), .hashTag(let tagRight)): return tagLeft == tagRight - case (.account(let accountLeft), account(let accountRight)): + case (.account(let accountLeft), .account(let accountRight)): return accountLeft == accountRight + case (.bottomLoader, .bottomLoader): + return true default: return false } @@ -34,6 +38,8 @@ extension SearchResultItem: Hashable { hasher.combine(account) case .hashTag(let tag): hasher.combine(tag) + case .bottomLoader: + hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) } } } diff --git a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift similarity index 74% rename from Mastodon/Diffiable/Section/RecomendHashTagSection.swift rename to Mastodon/Diffiable/Section/RecommendHashTagSection.swift index 2f78e73b9..502086910 100644 --- a/Mastodon/Diffiable/Section/RecomendHashTagSection.swift +++ b/Mastodon/Diffiable/Section/RecommendHashTagSection.swift @@ -1,5 +1,5 @@ // -// RecomendHashTagSection.swift +// RecommendHashTagSection.swift // Mastodon // // Created by sxiaojian on 2021/4/1. @@ -9,14 +9,14 @@ import Foundation import MastodonSDK import UIKit -enum RecomendHashTagSection: Equatable, Hashable { +enum RecommendHashTagSection: Equatable, Hashable { case main } -extension RecomendHashTagSection { +extension RecommendHashTagSection { static func collectionViewDiffableDataSource( for collectionView: UICollectionView - ) -> UICollectionViewDiffableDataSource { + ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, tag -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self), for: indexPath) as! SearchRecommendTagsCollectionViewCell cell.config(with: tag) diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 9c481a53a..91e443bdc 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -12,6 +12,7 @@ import UIKit enum SearchResultSection: Equatable, Hashable { case account case hashTag + case bottomLoader } extension SearchResultSection { @@ -19,14 +20,20 @@ extension SearchResultSection { for tableView: UITableView ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell switch result { case .account(let account): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: account) + return cell case .hashTag(let tag): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: tag) + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + cell.startAnimating() + return cell } - return cell } } } diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..91c2e3d66 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,20 @@ +// +// Array.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/7. +// + +import Foundation + +public extension Array where Element: Equatable { + + func removeDuplicate() -> Array { + return self.enumerated().filter { (index,value) -> Bool in + return self.firstIndex(of: value) == index + }.map { (_, value) in + value + } + } +} + diff --git a/Mastodon/Scene/Search/SearchViewController+Recomend.swift b/Mastodon/Scene/Search/SearchViewController+Recomend.swift index ff6ba7d17..f2e916ab7 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recomend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recomend.swift @@ -25,22 +25,6 @@ extension SearchViewController { hashTagCollectionView.constrain([ hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) - - viewModel.requestRecommendHashTags() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.viewModel.recommendHashTags.isEmpty { - let dataSource = RecomendHashTagSection.collectionViewDiffableDataSource(for: self.hashTagCollectionView) - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.viewModel.recommendHashTags, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.hashTagDiffableDataSource = dataSource - } - } receiveValue: { _ in - } - .store(in: &disposeBag) } func setupAccountsCollectionView() { @@ -57,22 +41,6 @@ extension SearchViewController { accountsCollectionView.constrain([ accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) ]) - - viewModel.requestRecommendAccounts() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - if !self.viewModel.recommendAccounts.isEmpty { - let dataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: self.accountsCollectionView) - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.viewModel.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.accountDiffableDataSource = dataSource - } - } receiveValue: { _ in - } - .store(in: &disposeBag) } override func viewDidLayoutSubviews() { diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index b46714832..51281f30c 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -6,12 +6,14 @@ // import Foundation +import MastodonSDK import UIKit extension SearchViewController { func setupSearchingTableView() { searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) + searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) view.addSubview(searchingTableView) searchingTableView.constrain([ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), @@ -20,35 +22,11 @@ extension SearchViewController { searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), ]) - + searchingTableView.tableFooterView = UIView() viewModel.isSearching .receive(on: DispatchQueue.main) - .sink {[weak self] isSearching in + .sink { [weak self] isSearching in self?.searchingTableView.isHidden = !isSearching - if !isSearching { - self?.searchResultDiffableDataSource = nil - } - } - .store(in: &disposeBag) - - viewModel.searchResult - .receive(on: DispatchQueue.main) - .sink { [weak self] searchResult in - guard let self = self else { return } - let dataSource = SearchResultSection.tableViewDiffableDataSource(for: self.searchingTableView) - var snapshot = NSDiffableDataSourceSnapshot() - if let accounts = searchResult?.accounts { - snapshot.appendSections([.account]) - let items = accounts.compactMap { SearchResultItem.account(account: $0) } - snapshot.appendItems(items, toSection: .account) - } - if let tags = searchResult?.hashtags { - snapshot.appendSections([.hashTag]) - let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } - snapshot.appendItems(items, toSection: .hashTag) - } - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - self.searchResultDiffableDataSource = dataSource } .store(in: &disposeBag) } @@ -60,8 +38,10 @@ extension SearchViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { 66 } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 66 } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index a0a25fef6..0b26cdb40 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -6,6 +6,7 @@ // import Combine +import GameplayKit import MastodonSDK import UIKit @@ -25,7 +26,7 @@ final class SearchViewController: UIViewController, NeedsDependency { searchBar.setImage(micImage, for: .bookmark, state: .normal) searchBar.showsBookmarkButton = true searchBar.showsScopeBar = false - searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people,L10n.Scene.Search.Searching.Segment.hashtags] + searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags] return searchBar }() @@ -59,9 +60,6 @@ final class SearchViewController: UIViewController, NeedsDependency { view.translatesAutoresizingMaskIntoConstraints = false return view }() - - var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? - var accountDiffableDataSource: UICollectionViewDiffableDataSource? let accountsCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() @@ -83,7 +81,6 @@ final class SearchViewController: UIViewController, NeedsDependency { tableView.backgroundColor = .white return tableView }() - var searchResultDiffableDataSource: UITableViewDiffableDataSource? } extension SearchViewController { @@ -97,6 +94,7 @@ extension SearchViewController { setupHashTagCollectionView() setupAccountsCollectionView() setupSearchingTableView() + setupDataSource() } func setupScrollView() { @@ -118,6 +116,20 @@ extension SearchViewController { scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), ]) } + + func setupDataSource() { + viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) + viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView) + } +} + +extension SearchViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView == searchingTableView { + handleScrollViewDidScroll(scrollView) + } + } } extension SearchViewController: UISearchBarDelegate { @@ -150,16 +162,24 @@ extension SearchViewController: UISearchBarDelegate { case 0: viewModel.searchScope.value = "" case 1: - viewModel.searchScope.value = "accounts" + viewModel.searchScope.value = Mastodon.API.Search.Scope.accounts.rawValue case 2: - viewModel.searchScope.value = "hashtags" + viewModel.searchScope.value = Mastodon.API.Search.Scope.hashTags.rawValue default: break } } + func searchBarBookmarkButtonClicked(_ searchBar: UISearchBar) {} } +extension SearchViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = SearchBottomLoader + typealias LoadingState = SearchViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { searchingTableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } +} + #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift new file mode 100644 index 000000000..fb57b6d34 --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -0,0 +1,141 @@ +// +// SearchViewModel+LoadOldestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import GameplayKit +import MastodonSDK +import os.log + +extension SearchViewModel { + class LoadOldestState: GKState { + weak var viewModel: SearchViewModel? + + init(viewModel: SearchViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension SearchViewModel.LoadOldestState { + class Initial: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard viewModel.searchResult.value != nil else { return false } + return stateClass == Loading.self + } + } + + class Loading: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + guard let oldSearchResult = viewModel.searchResult.value else { + stateMachine.enter(Fail.self) + return + } + var offset = 0 + switch viewModel.searchScope.value { + case Mastodon.API.Search.Scope.accounts.rawValue: + offset = oldSearchResult.accounts.count + case Mastodon.API.Search.Scope.hashTags.rawValue: + offset = oldSearchResult.hashtags.count + default: + return + } + let query = Mastodon.API.Search.Query(accountID: nil, + maxID: nil, + minID: nil, + type: viewModel.searchScope.value, + excludeUnreviewed: nil, + q: viewModel.searchText.value, + resolve: nil, + limit: nil, + offset: offset, + following: nil) + viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: load oldest search failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { result in + switch viewModel.searchScope.value { + case Mastodon.API.Search.Scope.accounts.rawValue: + if result.value.accounts.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newAccounts = [Mastodon.Entity.Account]() + newAccounts.append(contentsOf: oldSearchResult.accounts) + newAccounts.append(contentsOf: result.value.accounts) + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) + stateMachine.enter(Idle.self) + } + case Mastodon.API.Search.Scope.hashTags.rawValue: + if result.value.hashtags.isEmpty { + stateMachine.enter(NoMore.self) + } else { + var newTags = [Mastodon.Entity.Tag]() + newTags.append(contentsOf: oldSearchResult.hashtags) + newTags.append(contentsOf: result.value.hashtags) + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags.removeDuplicate()) + stateMachine.enter(Idle.self) + } + default: + return + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + stateClass == Loading.self + } + } + + class NoMore: SearchViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 03f689a1b..4fbdab5ba 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import GameplayKit import MastodonSDK import OSLog import UIKit @@ -28,6 +29,26 @@ final class SearchViewModel { var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [Mastodon.Entity.Account]() + var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? + var searchResultDiffableDataSource: UITableViewDiffableDataSource? + + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + init(context: AppContext) { self.context = context guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -60,10 +81,66 @@ final class SearchViewModel { isSearching .sink { [weak self] isSearching in if !isSearching { - self?.searchResult.value == nil + self?.searchResult.value = nil } } .store(in: &disposeBag) + + requestRecommendHashTags() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendHashTags.isEmpty { + guard let dataSource = self.hashTagDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendHashTags, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendAccounts.isEmpty { + guard let dataSource = self.accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + + searchResult + .receive(on: DispatchQueue.main) + .sink { [weak self] searchResult in + guard let self = self else { return } + guard let dataSource = self.searchResultDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + if let accounts = searchResult?.accounts { + snapshot.appendSections([.account]) + let items = accounts.compactMap { SearchResultItem.account(account: $0) } + snapshot.appendItems(items, toSection: .account) + if self.searchScope.value == Mastodon.API.Search.Scope.accounts.rawValue { + snapshot.appendItems([.bottomLoader], toSection: .account) + } + } + if let tags = searchResult?.hashtags { + snapshot.appendSections([.hashTag]) + let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } + snapshot.appendItems(items, toSection: .hashTag) + if self.searchScope.value == Mastodon.API.Search.Scope.hashTags.rawValue { + snapshot.appendItems([.bottomLoader], toSection: .hashTag) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) } func requestRecommendHashTags() -> Future { diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift new file mode 100644 index 000000000..dcd4c4971 --- /dev/null +++ b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift @@ -0,0 +1,47 @@ +// +// SearchBottomLoader.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/6. +// + +import Foundation +import UIKit + +final class SearchBottomLoader: UITableViewCell { + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.tintColor = Asset.Colors.Label.primary.color + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() + + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + func startAnimating() { + activityIndicatorView.startAnimating() + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + } + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.lightWhite.color + contentView.addSubview(activityIndicatorView) + activityIndicatorView.constrainToCenter() + } +} diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index ceb35678a..379d720ea 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -6,8 +6,8 @@ // import Foundation -import UIKit import MastodonSDK +import UIKit final class SearchingTableViewCell: UITableViewCell { let _imageView: UIImageView = { @@ -50,7 +50,7 @@ final class SearchingTableViewCell: UITableViewCell { extension SearchingTableViewCell { private func configure() { - self.selectionStyle = .none + selectionStyle = .none contentView.addSubview(_imageView) _imageView.pin(toSize: CGSize(width: 42, height: 42)) _imageView.constrain([ @@ -65,28 +65,28 @@ extension SearchingTableViewCell { _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0) } - func config(with account:Mastodon.Entity.Account) { - self._imageView.af.setImage( + func config(with account: Mastodon.Entity.Account) { + _imageView.af.setImage( withURL: URL(string: account.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) - self._titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName - self._subTitleLabel.text = account.acct + _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _subTitleLabel.text = account.acct } - func config(with tag:Mastodon.Entity.Tag) { + func config(with tag: Mastodon.Entity.Tag) { let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) - self._imageView.image = image - self._titleLabel.text = "# " + tag.name + _imageView.image = image + _titleLabel.text = "# " + tag.name guard let historys = tag.history else { - self._subTitleLabel.text = "" + _subTitleLabel.text = "" return } - let recentHistory = historys[0...2] - let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) + let recentHistory = historys[0 ... 2] + let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) - self._subTitleLabel.text = string + _subTitleLabel.text = string } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 8f266437f..465c133f2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -49,8 +49,8 @@ extension Mastodon.API.Search { } } -extension Mastodon.API.Search { - public struct Query: Codable, GetQuery { +public extension Mastodon.API.Search { + struct Query: Codable, GetQuery { public init(accountID: Mastodon.Entity.Account.ID?, maxID: Mastodon.Entity.Status.ID?, minID: Mastodon.Entity.Status.ID?, type: String?, excludeUnreviewed: Bool?, q: String, resolve: Bool?, limit: Int?, offset: Int?, following: Bool?) { self.accountID = accountID self.maxID = maxID @@ -93,3 +93,19 @@ extension Mastodon.API.Search { } } } + +public extension Mastodon.API.Search { + enum Scope: String { + case accounts + case hashTags + + public var rawValue: String { + switch self { + case .accounts: + return "accounts" + case .hashTags: + return "hashtags" + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift index f10339664..44446d0d9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+SearchResult.swift @@ -8,6 +8,12 @@ import Foundation extension Mastodon.Entity { public struct SearchResult: Codable { + public init(accounts: [Mastodon.Entity.Account], statuses: [Mastodon.Entity.Status], hashtags: [Mastodon.Entity.Tag]) { + self.accounts = accounts + self.statuses = statuses + self.hashtags = hashtags + } + public let accounts: [Mastodon.Entity.Account] public let statuses: [Mastodon.Entity.Status] public let hashtags: [Mastodon.Entity.Tag] From a61e662f3891ed2f546639b6c628ef59e30bf4da Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 13:57:03 +0800 Subject: [PATCH 183/400] fix: resolve requested changes --- Mastodon.xcodeproj/project.pbxproj | 4 + .../StatusWithGapFetchResultController.swift | 85 +++++++++++++++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 17 ++-- ...imelineViewController+StatusProvider.swift | 6 +- .../HashtagTimelineViewController.swift | 2 +- .../HashtagTimelineViewModel+Diffable.swift | 13 +-- .../Welcome/WelcomeViewController.swift | 2 +- .../UserTimelineViewModel+State.swift | 7 ++ .../API/Mastodon+API+Favorites.swift | 2 +- 9 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 824b60ad1..9af5bd234 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -356,6 +357,7 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1659,6 +1661,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2137,6 +2140,7 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift new file mode 100644 index 000000000..f392c893d --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift @@ -0,0 +1,85 @@ +// +// StatusWithGapFetchResultController.swift +// Mastodon +// +// Created by BradGao on 2021/4/7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +class StatusWithGapFetchResultController: NSObject { + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + let domain = CurrentValueSubject(nil) + let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) + + var needLoadMiddleIndex: Int? = nil + + // output + let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + self.domain.value = domain ?? "" + self.fetchedResultsController = { + let fetchRequest = Status.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + Publishers.CombineLatest( + self.domain.removeDuplicates().eraseToAnyPublisher(), + self.statusIDs.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] domain, ids in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + Status.predicate(domain: domain ?? "", ids: ids), + additionalTweetPredicate + ]) + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let indexes = statusIDs.value + let objects = fetchedResultsController.fetchedObjects ?? [] + + let items: [NSManagedObjectID] = objects + .compactMap { object in + indexes.firstIndex(of: object.id).map { index in (index, object) } + } + .sorted { $0.0 < $1.0 } + .map { $0.1.objectID } + self.objectIDs.value = items + } +} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 52a7bf2f4..03211e3d3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,7 +56,8 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) - var injectedContent: String? = nil + // In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users. + var preInsertedContent: String? = nil // custom emojis var customEmojiViewModelSubscription: AnyCancellable? @@ -74,11 +75,11 @@ final class ComposeViewModel { init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind, - injectedContent: String? = nil + preInsertedContent: String? = nil ) { self.context = context self.composeKind = composeKind - self.injectedContent = injectedContent + self.preInsertedContent = preInsertedContent switch composeKind { case .post: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) @@ -204,9 +205,9 @@ final class ComposeViewModel { if content.isEmpty { return true } - // if injectedContent plus a space is equal to the content, simply dismiss the modal - if let injectedContent = self?.injectedContent { - return content == (injectedContent + " ") + // if preInsertedContent plus a space is equal to the content, simply dismiss the modal + if let preInsertedContent = self?.preInsertedContent { + return content == (preInsertedContent + " ") } return false } @@ -316,9 +317,9 @@ final class ComposeViewModel { }) .store(in: &disposeBag) - if let injectedContent = injectedContent { + if let preInsertedContent = preInsertedContent { // add a space after the injected text - composeStatusAttribute.composeContent.send(injectedContent + " ") + composeStatusAttribute.composeContent.send(preInsertedContent + " ") } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index 7263e6ab8..23068b7bc 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -32,11 +32,11 @@ extension HashtagTimelineViewController: StatusProvider { } switch item { - case .homeTimelineIndex(let objectID, _): + case .status(let objectID, _): let managedObjectContext = self.viewModel.context.managedObjectContext managedObjectContext.perform { - let timelineIndex = managedObjectContext.object(with: objectID) as? HomeTimelineIndex - promise(.success(timelineIndex?.status)) + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) } default: promise(.success(nil)) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index c831cf215..1dbb0323c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -165,7 +165,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, injectedContent: "#\(viewModel.hashTag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)") coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 9a6102e09..a0bf5d82d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -55,14 +55,17 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { let oldSnapshot = diffableDataSource.snapshot() let snapshot = snapshot as NSDiffableDataSourceSnapshot + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + let statusItemList: [Item] = snapshot.itemIdentifiers.map { let status = managedObjectContext.object(with: $0) as! Status - let isStatusTextSensitive: Bool = { - guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - return Item.status(objectID: $0, attribute: Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: status.sensitive)) + let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() + return Item.status(objectID: $0, attribute: attribute) } var newSnapshot = NSDiffableDataSourceSnapshot() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index 838f1327a..c647d04ca 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -222,6 +222,6 @@ extension WelcomeViewController: OnboardingViewControllerAppearance { } extension WelcomeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { // make underneath view controller alive to fix layout issue due to view life cycle - return .overFullScreen + return .fullScreen } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 520fa43e5..cbd87e335 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -258,5 +258,12 @@ extension UserTimelineViewModel.State { return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + } } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index cb01e83eb..64598bc14 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -121,7 +121,7 @@ extension Mastodon.API.Favorites { case destroy } - public struct ListQuery: GetQuery,PagedQueryType { + public struct ListQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? From 2d65bda7fe4dec0a7b31b0a0bb88a3f1a88902d4 Mon Sep 17 00:00:00 2001 From: jk234ert Date: Wed, 7 Apr 2021 16:37:05 +0800 Subject: [PATCH 184/400] chore: migrate HashtagViewModel to use `StatusFetchedResultsController` --- Mastodon.xcodeproj/project.pbxproj | 4 - .../StatusFetchedResultsController.swift | 11 +-- .../StatusWithGapFetchResultController.swift | 85 ------------------- .../HashtagTimelineViewController.swift | 2 + .../HashtagTimelineViewModel+Diffable.swift | 23 ++--- ...tagTimelineViewModel+LoadLatestState.swift | 12 +-- ...tagTimelineViewModel+LoadMiddleState.swift | 16 ++-- ...tagTimelineViewModel+LoadOldestState.swift | 12 +-- .../HashtagTimelineViewModel.swift | 38 ++------- 9 files changed, 42 insertions(+), 161 deletions(-) delete mode 100644 Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 9af5bd234..824b60ad1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -357,7 +356,6 @@ /* Begin PBXFileReference section */ 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWithGapFetchResultController.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -1661,7 +1659,6 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, - 0F1E2FE8261D7FD000C38565 /* StatusWithGapFetchResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2140,7 +2137,6 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - 0F1E2FE9261D7FD000C38565 /* StatusWithGapFetchResultController.swift in Sources */, DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift index a61429ab8..dd373b29f 100644 --- a/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift +++ b/Mastodon/Diffiable/FetchedResultsController/StatusFetchedResultsController.swift @@ -25,7 +25,7 @@ final class StatusFetchedResultsController: NSObject { // output let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { + init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate?) { self.domain.value = domain ?? "" self.fetchedResultsController = { let fetchRequest = Status.sortedFetchRequest @@ -52,10 +52,11 @@ final class StatusFetchedResultsController: NSObject { .receive(on: DispatchQueue.main) .sink { [weak self] domain, ids in guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) + var predicates = [Status.predicate(domain: domain ?? "", ids: ids)] + if let additionalPredicate = additionalTweetPredicate { + predicates.append(additionalPredicate) + } + self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) do { try self.fetchedResultsController.performFetch() } catch { diff --git a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift b/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift deleted file mode 100644 index f392c893d..000000000 --- a/Mastodon/Diffiable/FetchedResultsController/StatusWithGapFetchResultController.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StatusWithGapFetchResultController.swift -// Mastodon -// -// Created by BradGao on 2021/4/7. -// - -import os.log -import UIKit -import Combine -import CoreData -import CoreDataStack -import MastodonSDK - -class StatusWithGapFetchResultController: NSObject { - var disposeBag = Set() - - let fetchedResultsController: NSFetchedResultsController - - // input - let domain = CurrentValueSubject(nil) - let statusIDs = CurrentValueSubject<[Mastodon.Entity.Status.ID], Never>([]) - - var needLoadMiddleIndex: Int? = nil - - // output - let objectIDs = CurrentValueSubject<[NSManagedObjectID], Never>([]) - - init(managedObjectContext: NSManagedObjectContext, domain: String?, additionalTweetPredicate: NSPredicate) { - self.domain.value = domain ?? "" - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() - super.init() - - fetchedResultsController.delegate = self - - Publishers.CombineLatest( - self.domain.removeDuplicates().eraseToAnyPublisher(), - self.statusIDs.removeDuplicates().eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] domain, ids in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - Status.predicate(domain: domain ?? "", ids: ids), - additionalTweetPredicate - ]) - do { - try self.fetchedResultsController.performFetch() - } catch { - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } -} - -// MARK: - NSFetchedResultsControllerDelegate -extension StatusWithGapFetchResultController: NSFetchedResultsControllerDelegate { - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - let indexes = statusIDs.value - let objects = fetchedResultsController.fetchedObjects ?? [] - - let items: [NSManagedObjectID] = objects - .compactMap { object in - indexes.firstIndex(of: object.id).map { index in (index, object) } - } - .sorted { $0.0 < $1.0 } - .map { $0.1.objectID } - self.objectIDs.value = items - } -} diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 1dbb0323c..9d638e6c6 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -107,6 +107,8 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) + + } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index a0bf5d82d..26f32a33c 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -33,14 +33,9 @@ extension HashtagTimelineViewModel { } } -// MARK: - NSFetchedResultsControllerDelegate -extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { - - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - - func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { +// MARK: - Compare old & new snapshots and generate new items +extension HashtagTimelineViewModel { + func generateStatusItems(newObjectIDs: [NSManagedObjectID]) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let tableView = self.tableView else { return } @@ -48,12 +43,12 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { guard let diffableDataSource = self.diffableDataSource else { return } - let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let parentManagedObjectContext = fetchedResultsController.fetchedResultsController.managedObjectContext let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) managedObjectContext.parent = parentManagedObjectContext let oldSnapshot = diffableDataSource.snapshot() - let snapshot = snapshot as NSDiffableDataSourceSnapshot +// let snapshot = snapshot as NSDiffableDataSourceSnapshot var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] for item in oldSnapshot.itemIdentifiers { @@ -61,9 +56,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { oldSnapshotAttributeDict[objectID] = attribute } - let statusItemList: [Item] = snapshot.itemIdentifiers.map { - let status = managedObjectContext.object(with: $0) as! Status - + let statusItemList: [Item] = newObjectIDs.map { let attribute = oldSnapshotAttributeDict[$0] ?? Item.StatusAttribute() return Item.status(objectID: $0, attribute: attribute) } @@ -75,7 +68,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { if let needLoadMiddleIndex = needLoadMiddleIndex, needLoadMiddleIndex < (statusItemList.count - 1) { // If yes, insert a `middleLoader` at the index var newItems = statusItemList - newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: snapshot.itemIdentifiers[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) + newItems.insert(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: newObjectIDs[needLoadMiddleIndex]), at: (needLoadMiddleIndex + 1)) newSnapshot.appendItems(newItems, toSection: .main) } else { newSnapshot.appendItems(statusItemList, toSection: .main) @@ -112,6 +105,7 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { newSnapshot: NSDiffableDataSourceSnapshot ) -> Difference? { guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let item = oldSnapshot.itemIdentifiers.first as? Item, case Item.status = item else { return nil } let oldItemAtBeginning = oldSnapshot.itemIdentifiers(inSection: .main).first! @@ -127,5 +121,4 @@ extension HashtagTimelineViewModel: NSFetchedResultsControllerDelegate { } return nil } - } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index d8e286195..e772e8ea0 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -73,18 +73,18 @@ extension HashtagTimelineViewModel.LoadLatestState { // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load - if !viewModel.hashtagStatusIDList.isEmpty, let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if !oldStatusIDs.isEmpty, let lastNewStatusID = newStatusIDList.last, + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = (newStatusIDList.count - 1) } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: 0) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: 0) + let newIDs = oldStatusIDs.removingDuplicates() - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = newIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index e971659e1..9bf87554b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -54,11 +54,11 @@ extension HashtagTimelineViewModel.LoadMiddleState { return } - guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { + guard let upperStatusObject = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).first(where: { $0.objectID == upperStatusObjectID }) else { stateMachine.enter(Fail.self) return } - let statusIDs = (viewModel.fetchedResultsController.fetchedObjects ?? []).compactMap { status in + let statusIDs = (viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).compactMap { status in status.id } @@ -86,27 +86,27 @@ extension HashtagTimelineViewModel.LoadMiddleState { let newStatusIDList = response.value.map { $0.id } - if let indexToInsert = viewModel.hashtagStatusIDList.firstIndex(of: maxID) { + var oldStatusIDs = viewModel.fetchedResultsController.statusIDs.value + if let indexToInsert = oldStatusIDs.firstIndex(of: maxID) { // When response data: // 1. is not empty // 2. last status are not recorded // Then we may have middle data to load if let lastNewStatusID = newStatusIDList.last, - !viewModel.hashtagStatusIDList.contains(lastNewStatusID) { + !oldStatusIDs.contains(lastNewStatusID) { viewModel.needLoadMiddleIndex = indexToInsert + newStatusIDList.count } else { viewModel.needLoadMiddleIndex = nil } - viewModel.hashtagStatusIDList.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) - viewModel.hashtagStatusIDList.removeDuplicates() + oldStatusIDs.insert(contentsOf: newStatusIDList, at: indexToInsert + 1) + oldStatusIDs.removeDuplicates() } else { // Only when the hashtagStatusIDList changes, we could not find the `loadMiddleState` index // Then there is no need to set a `loadMiddleState` cell viewModel.needLoadMiddleIndex = nil } - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + viewModel.fetchedResultsController.statusIDs.value = oldStatusIDs } .store(in: &viewModel.disposeBag) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index d464d3a50..23ec99152 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -29,7 +29,7 @@ extension HashtagTimelineViewModel.LoadOldestState { class Initial: HashtagTimelineViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { guard let viewModel = viewModel else { return false } - guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + guard !(viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } return stateClass == Loading.self } } @@ -48,7 +48,7 @@ extension HashtagTimelineViewModel.LoadOldestState { return } - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + guard let last = viewModel.fetchedResultsController.fetchedResultsController.fetchedObjects?.last else { stateMachine.enter(Idle.self) return } @@ -79,10 +79,10 @@ extension HashtagTimelineViewModel.LoadOldestState { } else { stateMachine.enter(Idle.self) } - let newStatusIDList = statuses.map { $0.id } - viewModel.hashtagStatusIDList.append(contentsOf: newStatusIDList) - let newPredicate = Status.predicate(domain: activeMastodonAuthenticationBox.domain, ids: viewModel.hashtagStatusIDList) - viewModel.timelinePredicate.send(newPredicate) + var newStatusIDs = viewModel.fetchedResultsController.statusIDs.value + let fetchedStatusIDList = statuses.map { $0.id } + newStatusIDs.append(contentsOf: fetchedStatusIDList) + viewModel.fetchedResultsController.statusIDs.value = newStatusIDs } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index e7f167f2a..a6b1b0594 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -19,12 +19,11 @@ final class HashtagTimelineViewModel: NSObject { var disposeBag = Set() - var hashtagStatusIDList = [Mastodon.Entity.Status.ID]() var needLoadMiddleIndex: Int? = nil // input let context: AppContext - let fetchedResultsController: NSFetchedResultsController + let fetchedResultsController: StatusFetchedResultsController let isFetchingLatestTimeline = CurrentValueSubject(false) let timelinePredicate = CurrentValueSubject(nil) let hashtagEntity = CurrentValueSubject(nil) @@ -69,39 +68,14 @@ final class HashtagTimelineViewModel: NSObject { init(context: AppContext, hashTag: String) { self.context = context self.hashTag = hashTag - self.fetchedResultsController = { - let fetchRequest = Status.sortedFetchRequest - fetchRequest.returnsObjectsAsFaults = false - fetchRequest.fetchBatchSize = 20 - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - - return controller - }() + let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value + self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) super.init() - fetchedResultsController.delegate = self - - timelinePredicate + fetchedResultsController.objectIDs .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] predicate in - guard let self = self else { return } - self.fetchedResultsController.fetchRequest.predicate = predicate - do { - self.diffableDataSource?.defaultRowAnimation = .fade - try self.fetchedResultsController.performFetch() - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in - guard let self = self else { return } - self.diffableDataSource?.defaultRowAnimation = .automatic - } - } catch { - assertionFailure(error.localizedDescription) - } + .sink { [weak self] objectIds in + self?.generateStatusItems(newObjectIDs: objectIds) } .store(in: &disposeBag) } From ecd595c6e80464e902d3f91bbcfcacdf3ed7a9ec Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 16:56:31 +0800 Subject: [PATCH 185/400] chore: correct hashtag typo --- .../Protocol/StatusProvider/StatusProviderFacade.swift | 2 +- .../HashtagTimelineViewController.swift | 8 ++++---- .../HashtagTimelineViewModel+LoadLatestState.swift | 2 +- .../HashtagTimelineViewModel+LoadMiddleState.swift | 2 +- .../HashtagTimelineViewModel+LoadOldestState.swift | 2 +- .../HashtagTimeline/HashtagTimelineViewModel.swift | 10 +++++----- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index b94391abe..d1c24c97f 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -67,7 +67,7 @@ extension StatusProviderFacade { static func responseToStatusActiveLabelAction(provider: StatusProvider, cell: UITableViewCell, activeLabel: ActiveLabel, didTapEntity entity: ActiveEntity) { switch entity.type { case .hashtag(let text, _): - let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashTag: text) + let hashtagTimelienViewModel = HashtagTimelineViewModel(context: provider.context, hashtag: text) provider.coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelienViewModel), from: provider, transition: .show) case .mention(let text, _): coordinateToStatusMentionProfileScene(for: .primary, provider: provider, cell: cell, mention: text) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 9d638e6c6..a70fe7929 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -53,8 +53,8 @@ extension HashtagTimelineViewController { override func viewDidLoad() { super.viewDidLoad() - title = "#\(viewModel.hashTag)" - titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: nil) + title = "#\(viewModel.hashtag)" + titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color @@ -142,7 +142,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.updateTitle(hashtag: viewModel.hashTag, peopleNumber: subtitle) + titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle) } guard let histories = viewModel.hashtagEntity.value?.history else { return @@ -167,7 +167,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashTag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashtag)") coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift index e772e8ea0..b2d121d50 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadLatestState.swift @@ -50,7 +50,7 @@ extension HashtagTimelineViewModel.LoadLatestState { // TODO: only set large count when using Wi-Fi viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .receive(on: DispatchQueue.main) .sink { completion in diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift index 9bf87554b..dcd3f81ac 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadMiddleState.swift @@ -67,7 +67,7 @@ extension HashtagTimelineViewModel.LoadMiddleState { viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index 23ec99152..d0607550e 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -58,7 +58,7 @@ extension HashtagTimelineViewModel.LoadOldestState { viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, - hashtag: viewModel.hashTag, + hashtag: viewModel.hashtag, authorizationBox: activeMastodonAuthenticationBox) .delay(for: .seconds(1), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index a6b1b0594..b43b67143 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -15,7 +15,7 @@ import MastodonSDK final class HashtagTimelineViewModel: NSObject { - let hashTag: String + let hashtag: String var disposeBag = Set() @@ -65,9 +65,9 @@ final class HashtagTimelineViewModel: NSObject { var cellFrameCache = NSCache() - init(context: AppContext, hashTag: String) { + init(context: AppContext, hashtag: String) { self.context = context - self.hashTag = hashTag + self.hashtag = hashtag let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value self.fetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: activeMastodonAuthenticationBox?.domain, additionalTweetPredicate: nil) super.init() @@ -84,13 +84,13 @@ final class HashtagTimelineViewModel: NSObject { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let query = Mastodon.API.Search.Query(q: hashTag, type: .hashtags) + let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags) context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { _ in } receiveValue: { [weak self] response in let matchedTag = response.value.hashtags.first { tag -> Bool in - return tag.name == self?.hashTag + return tag.name == self?.hashtag } self?.hashtagEntity.send(matchedTag) } From 08d105f7b706aff3e5bb198a6c4abab4252256c9 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 17:10:58 +0800 Subject: [PATCH 186/400] chore: make hashtag inject with compose kind --- .../Section/ComposeStatusSection.swift | 1 + .../Compose/ComposeViewModel+Diffable.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 28 ++++++++++++------- .../HashtagTimelineViewController.swift | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index ebf95a093..56aa32798 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -22,6 +22,7 @@ enum ComposeStatusSection: Equatable, Hashable { extension ComposeStatusSection { enum ComposeKind { case post + case hashtag(hashtag: String) case mention(mastodonUserObjectID: NSManagedObjectID) case reply(repliedToStatusObjectID: NSManagedObjectID) } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 496ba2845..4d5a39be1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -62,7 +62,7 @@ extension ComposeViewModel { case .reply(let statusObjectID): snapshot.appendItems([.replyTo(statusObjectID: statusObjectID)], toSection: .repliedTo) snapshot.appendItems([.input(replyToStatusObjectID: statusObjectID, attribute: composeStatusAttribute)], toSection: .repliedTo) - case .mention, .post: + case .hashtag, .mention, .post: snapshot.appendItems([.input(replyToStatusObjectID: nil, attribute: composeStatusAttribute)], toSection: .status) } diffableDataSource.apply(snapshot, animatingDifferences: false) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 39f732071..f52c38a17 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -56,8 +56,9 @@ final class ComposeViewModel { let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) - // In some specific scenes(hashtag scene e.g.), we need to display the compose scene with pre-inserted text(insert '#mastodon ' in #mastodon hashtag scene, e.g.), the pre-inserted text should be treated as mannually inputed by users. - var preInsertedContent: String? = nil + // for hashtag: #' ' + // for mention: @' ' + private(set) var preInsertedContent: String? // custom emojis var customEmojiViewModelSubscription: AnyCancellable? @@ -74,27 +75,34 @@ final class ComposeViewModel { init( context: AppContext, - composeKind: ComposeStatusSection.ComposeKind, - preInsertedContent: String? = nil + composeKind: ComposeStatusSection.ComposeKind ) { self.context = context self.composeKind = composeKind - self.preInsertedContent = preInsertedContent switch composeKind { - case .post, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) - case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) + case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) + case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init - - if case let .mention(mastodonUserObjectID) = composeKind { + if case let .hashtag(text) = composeKind { + let initialComposeContent = "#" + text + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent + } else if case let .mention(mastodonUserObjectID) = composeKind { context.managedObjectContext.performAndWait { let mastodonUser = context.managedObjectContext.object(with: mastodonUserObjectID) as! MastodonUser let initialComposeContent = "@" + mastodonUser.acct UITextChecker.learnWord(initialComposeContent) - self.composeStatusAttribute.composeContent.value = initialComposeContent + " " + let preInsertedContent = initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent } + } else { + self.preInsertedContent = nil } isCustomEmojiComposing diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index a70fe7929..cefd7b238 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -167,7 +167,7 @@ extension HashtagTimelineViewController { @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let composeViewModel = ComposeViewModel(context: context, composeKind: .post, preInsertedContent: "#\(viewModel.hashtag)") + let composeViewModel = ComposeViewModel(context: context, composeKind: .hashtag(hashtag: viewModel.hashtag)) coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } From d800e10bd7b069f61b812730695618a357330f7d Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 7 Apr 2021 19:49:33 +0800 Subject: [PATCH 187/400] feature: add search history --- .../CoreData.xcdatamodel/contents | 17 +- CoreDataStack/Entity/SearchHistory.swift | 54 +++++++ Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Item/SearchResultItem.swift | 13 ++ .../Section/SearchResultSection.swift | 16 +- .../SearchViewController+Searching.swift | 48 +++++- .../Scene/Search/SearchViewController.swift | 29 +++- Mastodon/Scene/Search/SearchViewModel.swift | 145 +++++++++++++++--- .../SearchingTableViewCell.swift | 31 +++- .../SearchRecommendCollectionHeader.swift | 2 +- 10 files changed, 325 insertions(+), 34 deletions(-) create mode 100644 CoreDataStack/Entity/SearchHistory.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index d655753d3..5f048880f 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -138,12 +138,18 @@ - + + + + + + + @@ -201,8 +207,9 @@ - - + + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift new file mode 100644 index 000000000..c1a81bc05 --- /dev/null +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -0,0 +1,54 @@ +// +// SearchHistory.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/7. +// + +import Foundation +import CoreData + +public final class SearchHistory: NSManagedObject { + public typealias ID = UUID + @NSManaged public private(set) var identifier: ID + @NSManaged public private(set) var createAt: Date + + @NSManaged public private(set) var account: MastodonUser? + @NSManaged public private(set) var hashTag: Tag? + +} + +extension SearchHistory { + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier)) + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + account: MastodonUser + ) -> SearchHistory { + let searchHistory: SearchHistory = context.insertObject() + searchHistory.account = account + searchHistory.createAt = Date() + return searchHistory + } + + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + hashTag: Tag + ) -> SearchHistory { + let searchHistory: SearchHistory = context.insertObject() + searchHistory.hashTag = hashTag + searchHistory.createAt = Date() + return searchHistory + } +} + +extension SearchHistory: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SearchHistory.createAt, ascending: false)] + } +} diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 165b9af31..214aece57 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; + 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; @@ -372,6 +373,7 @@ 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; @@ -1398,6 +1400,7 @@ isa = PBXGroup; children = ( DB89BA2625C110B4008580ED /* Status.swift */, + 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, 2D927F0125C7E4F2004F19B8 /* Mention.swift */, @@ -2333,6 +2336,7 @@ 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, + 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index 1156a05fa..56390f203 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -5,6 +5,7 @@ // Created by sxiaojian on 2021/4/6. // +import CoreData import Foundation import MastodonSDK @@ -13,6 +14,10 @@ enum SearchResultItem { case account(account: Mastodon.Entity.Account) + case accountObjectID(accountObjectID: NSManagedObjectID) + + case hashTagObjectID(hashTagObjectID: NSManagedObjectID) + case bottomLoader } @@ -25,6 +30,10 @@ extension SearchResultItem: Equatable { return accountLeft == accountRight case (.bottomLoader, .bottomLoader): return true + case (.accountObjectID(let idLeft),.accountObjectID(let idRight)): + return idLeft == idRight + case (.hashTagObjectID(let idLeft),.hashTagObjectID(let idRight)): + return idLeft == idRight default: return false } @@ -38,6 +47,10 @@ extension SearchResultItem: Hashable { hasher.combine(account) case .hashTag(let tag): hasher.combine(tag) + case .accountObjectID(let id): + hasher.combine(id) + case .hashTagObjectID(let id): + hasher.combine(id) case .bottomLoader: hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) } diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 91e443bdc..66f6891e4 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -8,16 +8,20 @@ import Foundation import MastodonSDK import UIKit +import CoreData +import CoreDataStack enum SearchResultSection: Equatable, Hashable { case account case hashTag + case mixed case bottomLoader } extension SearchResultSection { static func tableViewDiffableDataSource( - for tableView: UITableView + for tableView: UITableView, + dependency: NeedsDependency ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in switch result { @@ -29,6 +33,16 @@ extension SearchResultSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: tag) return cell + case .hashTagObjectID(let hashTagObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + let tag = dependency.context.managedObjectContext.object(with: hashTagObjectID) as! Tag + cell.config(with: tag) + return cell + case .accountObjectID(let accountObjectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell + let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + cell.config(with: user) + return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader cell.startAnimating() diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 51281f30c..34acf443f 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -5,8 +5,12 @@ // Created by sxiaojian on 2021/4/2. // +import Combine +import CoreData +import CoreDataStack import Foundation import MastodonSDK +import OSLog import UIKit extension SearchViewController { @@ -20,7 +24,7 @@ extension SearchViewController { searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) ]) searchingTableView.tableFooterView = UIView() viewModel.isSearching @@ -29,6 +33,42 @@ extension SearchViewController { self?.searchingTableView.isHidden = !isSearching } .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.isSearching, + viewModel.searchText + ) + .sink { [weak self] isSearching, text in + guard let self = self else { return } + if isSearching, text.isEmpty { + self.searchingTableView.tableHeaderView = self.searchHeader + } else { + self.searchingTableView.tableHeaderView = nil + } + } + .store(in: &disposeBag) + } + + func setupSearchHeader() { + searchHeader.addSubview(recentSearchesLabel) + recentSearchesLabel.constrain([ + recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16), + recentSearchesLabel.constraint(.centerY, toView: searchHeader) + ]) + + searchHeader.addSubview(clearSearchHistoryButton) + recentSearchesLabel.constrain([ + searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16), + clearSearchHistoryButton.constraint(.centerY, toView: searchHeader) + ]) + + clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside) + } +} + +extension SearchViewController { + @objc func clearAction(_ sender: UIButton) { + viewModel.deleteSearchHistory() } } @@ -43,5 +83,9 @@ extension SearchViewController: UITableViewDelegate { 66 } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.saveItemToCoreData(item: item) + } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 0b26cdb40..1dfa87e77 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -76,17 +76,39 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() + tableView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine - tableView.backgroundColor = .white return tableView }() + + lazy var searchHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56)) + return view + }() + + let recentSearchesLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.textColor = Asset.Colors.Label.primary.color + label.text = L10n.Scene.Search.Searching.recentSearch + return label + }() + + let clearSearchHistoryButton: UIButton = { + let button = UIButton(type: .custom) + button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) + button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal) + return button + }() } extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.search.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true @@ -95,6 +117,7 @@ extension SearchViewController { setupAccountsCollectionView() setupSearchingTableView() setupDataSource() + setupSearchHeader() } func setupScrollView() { @@ -120,7 +143,7 @@ extension SearchViewController { func setupDataSource() { viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView) viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) - viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView) + viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 4fbdab5ba..6827c7035 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -6,13 +6,15 @@ // import Combine +import CoreData +import CoreDataStack import Foundation import GameplayKit import MastodonSDK import OSLog import UIKit -final class SearchViewModel { +final class SearchViewModel: NSObject { var disposeBag = Set() // input @@ -51,41 +53,79 @@ final class SearchViewModel { init(context: AppContext) { self.context = context + super.init() + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } Publishers.CombineLatest( searchText - .filter { !$0.isEmpty } .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), - searchScope) - .flatMap { (text, scope) -> AnyPublisher, Error> in - let query = Mastodon.API.Search.Query(accountID: nil, - maxID: nil, - minID: nil, - type: scope, - excludeUnreviewed: nil, - q: text, - resolve: nil, - limit: nil, - offset: nil, - following: nil) - return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - } - .sink { _ in - } receiveValue: { [weak self] result in - self?.searchResult.value = result.value - } - .store(in: &disposeBag) + searchScope + ) + .filter { text, _ in + !text.isEmpty + } + .flatMap { (text, scope) -> AnyPublisher, Error> in + + let query = Mastodon.API.Search.Query(accountID: nil, + maxID: nil, + minID: nil, + type: scope, + excludeUnreviewed: nil, + q: text, + resolve: nil, + limit: nil, + offset: nil, + following: nil) + return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + } + .sink { _ in + } receiveValue: { [weak self] result in + self?.searchResult.value = result.value + } + .store(in: &disposeBag) isSearching .sink { [weak self] isSearching in if !isSearching { self?.searchResult.value = nil + self?.searchText.value = "" } } .store(in: &disposeBag) + Publishers.CombineLatest3( + isSearching, + searchText, + searchScope + ) + .filter { isSearching, text, _ in + isSearching && text.isEmpty + } + .sink { [weak self] _, _, scope in + guard let self = self else { return } + guard let searchHistories = self.fetchSearchHistory() else { return } + guard let dataSource = self.searchResultDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.mixed]) + + searchHistories.forEach { searchHistory in + let containsAccount = scope == Mastodon.API.Search.Scope.accounts.rawValue || scope == "" + let containsHashTag = scope == Mastodon.API.Search.Scope.hashTags.rawValue || scope == "" + if let mastodonUser = searchHistory.account, containsAccount { + let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + if let tag = searchHistory.hashTag, containsHashTag { + let item = SearchResultItem.hashTagObjectID(hashTagObjectID: tag.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + requestRecommendHashTags() .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -190,4 +230,67 @@ final class SearchViewModel { .store(in: &self.disposeBag) } } + + func saveItemToCoreData(item: SearchResultItem) { + _ = context.managedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + switch item { + case .account(let account): + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + // load request mastodon user + let requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.context.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + + case .hashTag(let tag): + let histories = tag.history?[0 ... 2].compactMap { history -> History in + History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + } + let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) + SearchHistory.insert(into: self.context.managedObjectContext, hashTag: tagInCoreData) + + default: + break + } + } + } + + func fetchSearchHistory() -> [SearchHistory]? { + let searchHistory: [SearchHistory]? = { + let request = SearchHistory.sortedFetchRequest + request.predicate = nil + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + + }() + return searchHistory + } + + func deleteSearchHistory() { + let result = fetchSearchHistory() + _ = context.managedObjectContext.performChanges { [weak self] in + result?.forEach { history in + self?.context.managedObjectContext.delete(history) + } + self?.isSearching.value = true + } + } } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 379d720ea..cdfcdce23 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -5,6 +5,8 @@ // Created by sxiaojian on 2021/4/2. // +import CoreData +import CoreDataStack import Foundation import MastodonSDK import UIKit @@ -12,7 +14,7 @@ import UIKit final class SearchingTableViewCell: UITableViewCell { let _imageView: UIImageView = { let imageView = UIImageView() - imageView.tintColor = .black + imageView.tintColor = Asset.Colors.Label.primary.color return imageView }() @@ -50,6 +52,7 @@ final class SearchingTableViewCell: UITableViewCell { extension SearchingTableViewCell { private func configure() { + backgroundColor = .clear selectionStyle = .none contentView.addSubview(_imageView) _imageView.pin(toSize: CGSize(width: 42, height: 42)) @@ -75,6 +78,16 @@ extension SearchingTableViewCell { _subTitleLabel.text = account.acct } + func config(with account: MastodonUser) { + _imageView.af.setImage( + withURL: URL(string: account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + _titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + _subTitleLabel.text = account.acct + } + func config(with tag: Mastodon.Entity.Tag) { let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) _imageView.image = image @@ -88,6 +101,22 @@ extension SearchingTableViewCell { let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string } + + func config(with tag: Tag) { + let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) + _imageView.image = image + _titleLabel.text = "# " + tag.name + guard let historys = tag.histories?.sorted(by: { + $0.createAt.compare($1.createAt) == .orderedAscending + }) else { + _subTitleLabel.text = "" + return + } + let recentHistory = historys[0 ... 2] + let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) + let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) + _subTitleLabel.text = string + } } #if canImport(SwiftUI) && DEBUG diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index 193da2c47..ebd60ac30 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -18,7 +18,7 @@ class SearchRecommendCollectionHeader: UIView { let descriptionLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.lightSecondaryText.color + label.textColor = Asset.Colors.Label.secondary.color label.font = .preferredFont(forTextStyle: .body) label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping From 33016d9cf479b250f4c5d3a492b3dc195d0e60d0 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 7 Apr 2021 21:01:32 +0800 Subject: [PATCH 188/400] chore: rename hashTag to hashtag --- .../CoreData.xcdatamodel/contents | 30 +++++++++---------- CoreDataStack/Entity/SearchHistory.swift | 6 ++-- .../Diffiable/Item/SearchResultItem.swift | 12 ++++---- .../Section/SearchResultSection.swift | 8 ++--- Mastodon/Generated/Assets.swift | 1 - .../Background/search.colorset/Contents.json | 20 ------------- ...earchRecommendTagsCollectionViewCell.swift | 10 +++---- .../SearchViewController+Recomend.swift | 20 ++++++------- .../Scene/Search/SearchViewController.swift | 6 ++-- .../SearchViewModel+LoadOldestState.swift | 4 +-- Mastodon/Scene/Search/SearchViewModel.swift | 26 ++++++++-------- .../MastodonSDK/API/Mastodon+API+Search.swift | 4 +-- 12 files changed, 63 insertions(+), 84 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5f048880f..3904eb9ec 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -148,7 +148,7 @@ - +
@@ -197,19 +197,19 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift index c1a81bc05..33b8a6010 100644 --- a/CoreDataStack/Entity/SearchHistory.swift +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -14,7 +14,7 @@ public final class SearchHistory: NSManagedObject { @NSManaged public private(set) var createAt: Date @NSManaged public private(set) var account: MastodonUser? - @NSManaged public private(set) var hashTag: Tag? + @NSManaged public private(set) var hashtag: Tag? } @@ -38,10 +38,10 @@ extension SearchHistory { @discardableResult public static func insert( into context: NSManagedObjectContext, - hashTag: Tag + hashtag: Tag ) -> SearchHistory { let searchHistory: SearchHistory = context.insertObject() - searchHistory.hashTag = hashTag + searchHistory.hashtag = hashtag searchHistory.createAt = Date() return searchHistory } diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index 56390f203..53a36e2e5 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -10,13 +10,13 @@ import Foundation import MastodonSDK enum SearchResultItem { - case hashTag(tag: Mastodon.Entity.Tag) + case hashtag(tag: Mastodon.Entity.Tag) case account(account: Mastodon.Entity.Account) case accountObjectID(accountObjectID: NSManagedObjectID) - case hashTagObjectID(hashTagObjectID: NSManagedObjectID) + case hashtagObjectID(hashtagObjectID: NSManagedObjectID) case bottomLoader } @@ -24,7 +24,7 @@ enum SearchResultItem { extension SearchResultItem: Equatable { static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool { switch (lhs, rhs) { - case (.hashTag(let tagLeft), .hashTag(let tagRight)): + case (.hashtag(let tagLeft), .hashtag(let tagRight)): return tagLeft == tagRight case (.account(let accountLeft), .account(let accountRight)): return accountLeft == accountRight @@ -32,7 +32,7 @@ extension SearchResultItem: Equatable { return true case (.accountObjectID(let idLeft),.accountObjectID(let idRight)): return idLeft == idRight - case (.hashTagObjectID(let idLeft),.hashTagObjectID(let idRight)): + case (.hashtagObjectID(let idLeft),.hashtagObjectID(let idRight)): return idLeft == idRight default: return false @@ -45,11 +45,11 @@ extension SearchResultItem: Hashable { switch self { case .account(let account): hasher.combine(account) - case .hashTag(let tag): + case .hashtag(let tag): hasher.combine(tag) case .accountObjectID(let id): hasher.combine(id) - case .hashTagObjectID(let id): + case .hashtagObjectID(let id): hasher.combine(id) case .bottomLoader: hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 66f6891e4..50c561605 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -13,7 +13,7 @@ import CoreDataStack enum SearchResultSection: Equatable, Hashable { case account - case hashTag + case hashtag case mixed case bottomLoader } @@ -29,13 +29,13 @@ extension SearchResultSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: account) return cell - case .hashTag(let tag): + case .hashtag(let tag): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell cell.config(with: tag) return cell - case .hashTagObjectID(let hashTagObjectID): + case .hashtagObjectID(let hashtagObjectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell - let tag = dependency.context.managedObjectContext.object(with: hashTagObjectID) as! Tag + let tag = dependency.context.managedObjectContext.object(with: hashtagObjectID) as! Tag cell.config(with: tag) return cell case .accountObjectID(let accountObjectID): diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 241388199..8276cfb20 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -43,7 +43,6 @@ internal enum Asset { internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") - internal static let search = ColorAsset(name: "Colors/Background/search") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let success = ColorAsset(name: "Colors/Background/success") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json deleted file mode 100644 index 838e44e44..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/search.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "232", - "green" : "225", - "red" : "217" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 7167658c0..4fe87e3f8 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -16,7 +16,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { return imageView }() - let hashTagTitleLabel: UILabel = { + let hashtagTitleLabel: UILabel = { let label = UILabel() label.textColor = .white label.font = .systemFont(ofSize: 20, weight: .semibold) @@ -66,8 +66,8 @@ extension SearchRecommendTagsCollectionViewCell { contentView.addSubview(backgroundImageView) backgroundImageView.constrain(toSuperviewEdges: nil) - contentView.addSubview(hashTagTitleLabel) - hashTagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) + contentView.addSubview(hashtagTitleLabel) + hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) contentView.addSubview(peopleLabel) peopleLabel.pinTopLeft(top: 46, left: 16) @@ -77,7 +77,7 @@ extension SearchRecommendTagsCollectionViewCell { } func config(with tag: Mastodon.Entity.Tag) { - hashTagTitleLabel.text = "# " + tag.name + hashtagTitleLabel.text = "# " + tag.name guard let historys = tag.history else { peopleLabel.text = "" return @@ -98,7 +98,7 @@ struct SearchRecommendTagsCollectionViewCell_Previews: PreviewProvider { Group { UIViewPreview { let cell = SearchRecommendTagsCollectionViewCell() - cell.hashTagTitleLabel.text = "# test" + cell.hashtagTitleLabel.text = "# test" cell.peopleLabel.text = "128 people are talking" return cell } diff --git a/Mastodon/Scene/Search/SearchViewController+Recomend.swift b/Mastodon/Scene/Search/SearchViewController+Recomend.swift index f2e916ab7..87f32261c 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recomend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recomend.swift @@ -15,15 +15,15 @@ extension SearchViewController { let header = SearchRecommendCollectionHeader() header.titleLabel.text = L10n.Scene.Search.Recommend.HashTag.title header.descriptionLabel.text = L10n.Scene.Search.Recommend.HashTag.description - header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashTagSeeAllButtonPressed(_:)), for: .touchUpInside) + header.seeAllButton.addTarget(self, action: #selector(SearchViewController.hashtagSeeAllButtonPressed(_:)), for: .touchUpInside) stackView.addArrangedSubview(header) - hashTagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) - hashTagCollectionView.delegate = self + hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) + hashtagCollectionView.delegate = self - stackView.addArrangedSubview(hashTagCollectionView) - hashTagCollectionView.constrain([ - hashTagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + stackView.addArrangedSubview(hashtagCollectionView) + hashtagCollectionView.constrain([ + hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) } @@ -45,7 +45,7 @@ extension SearchViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - hashTagCollectionView.collectionViewLayout.invalidateLayout() + hashtagCollectionView.collectionViewLayout.invalidateLayout() accountsCollectionView.collectionViewLayout.invalidateLayout() } } @@ -65,7 +65,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - if collectionView == hashTagCollectionView { + if collectionView == hashtagCollectionView { return 6 } else { return 12 @@ -73,7 +73,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - if collectionView == hashTagCollectionView { + if collectionView == hashtagCollectionView { return CGSize(width: 228, height: 130) } else { return CGSize(width: 257, height: 202) @@ -82,7 +82,7 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { } extension SearchViewController { - @objc func hashTagSeeAllButtonPressed(_ sender: UIButton) {} + @objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {} @objc func accountSeeAllButtonPressed(_ sender: UIButton) {} } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 1dfa87e77..9a8ab4804 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -49,7 +49,7 @@ final class SearchViewController: UIViewController, NeedsDependency { return stackView }() - let hashTagCollectionView: UICollectionView = { + let hashtagCollectionView: UICollectionView = { let flowLayout = UICollectionViewFlowLayout() flowLayout.scrollDirection = .horizontal let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) @@ -141,7 +141,7 @@ extension SearchViewController { } func setupDataSource() { - viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView) + viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) } @@ -187,7 +187,7 @@ extension SearchViewController: UISearchBarDelegate { case 1: viewModel.searchScope.value = Mastodon.API.Search.Scope.accounts.rawValue case 2: - viewModel.searchScope.value = Mastodon.API.Search.Scope.hashTags.rawValue + viewModel.searchScope.value = Mastodon.API.Search.Scope.hashtags.rawValue default: break } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index fb57b6d34..6306e2e6c 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -55,7 +55,7 @@ extension SearchViewModel.LoadOldestState { switch viewModel.searchScope.value { case Mastodon.API.Search.Scope.accounts.rawValue: offset = oldSearchResult.accounts.count - case Mastodon.API.Search.Scope.hashTags.rawValue: + case Mastodon.API.Search.Scope.hashtags.rawValue: offset = oldSearchResult.hashtags.count default: return @@ -91,7 +91,7 @@ extension SearchViewModel.LoadOldestState { viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) stateMachine.enter(Idle.self) } - case Mastodon.API.Search.Scope.hashTags.rawValue: + case Mastodon.API.Search.Scope.hashtags.rawValue: if result.value.hashtags.isEmpty { stateMachine.enter(NoMore.self) } else { diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 6827c7035..df13ac48c 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -31,7 +31,7 @@ final class SearchViewModel: NSObject { var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [Mastodon.Entity.Account]() - var hashTagDiffableDataSource: UICollectionViewDiffableDataSource? + var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? var accountDiffableDataSource: UICollectionViewDiffableDataSource? var searchResultDiffableDataSource: UITableViewDiffableDataSource? @@ -112,13 +112,13 @@ final class SearchViewModel: NSObject { searchHistories.forEach { searchHistory in let containsAccount = scope == Mastodon.API.Search.Scope.accounts.rawValue || scope == "" - let containsHashTag = scope == Mastodon.API.Search.Scope.hashTags.rawValue || scope == "" + let containsHashTag = scope == Mastodon.API.Search.Scope.hashtags.rawValue || scope == "" if let mastodonUser = searchHistory.account, containsAccount { let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) snapshot.appendItems([item], toSection: .mixed) } - if let tag = searchHistory.hashTag, containsHashTag { - let item = SearchResultItem.hashTagObjectID(hashTagObjectID: tag.objectID) + if let tag = searchHistory.hashtag, containsHashTag { + let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) snapshot.appendItems([item], toSection: .mixed) } } @@ -131,7 +131,7 @@ final class SearchViewModel: NSObject { .sink { [weak self] _ in guard let self = self else { return } if !self.recommendHashTags.isEmpty { - guard let dataSource = self.hashTagDiffableDataSource else { return } + guard let dataSource = self.hashtagDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(self.recommendHashTags, toSection: .main) @@ -166,16 +166,16 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.account]) let items = accounts.compactMap { SearchResultItem.account(account: $0) } snapshot.appendItems(items, toSection: .account) - if self.searchScope.value == Mastodon.API.Search.Scope.accounts.rawValue { + if self.searchScope.value == Mastodon.API.Search.Scope.accounts.rawValue && !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .account) } } if let tags = searchResult?.hashtags { - snapshot.appendSections([.hashTag]) - let items = tags.compactMap { SearchResultItem.hashTag(tag: $0) } - snapshot.appendItems(items, toSection: .hashTag) - if self.searchScope.value == Mastodon.API.Search.Scope.hashTags.rawValue { - snapshot.appendItems([.bottomLoader], toSection: .hashTag) + snapshot.appendSections([.hashtag]) + let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) } + snapshot.appendItems(items, toSection: .hashtag) + if self.searchScope.value == Mastodon.API.Search.Scope.hashtags.rawValue && !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .hashtag) } } dataSource.apply(snapshot, animatingDifferences: false, completion: nil) @@ -255,12 +255,12 @@ final class SearchViewModel: NSObject { let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) - case .hashTag(let tag): + case .hashtag(let tag): let histories = tag.history?[0 ... 2].compactMap { history -> History in History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) } let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) - SearchHistory.insert(into: self.context.managedObjectContext, hashTag: tagInCoreData) + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) default: break diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index 465c133f2..d4b8e8045 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -97,13 +97,13 @@ public extension Mastodon.API.Search { public extension Mastodon.API.Search { enum Scope: String { case accounts - case hashTags + case hashtags public var rawValue: String { switch self { case .accounts: return "accounts" - case .hashTags: + case .hashtags: return "hashtags" } } From 27b698a97a3725974dcc5ad41c171da1b6cb1cdf Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 7 Apr 2021 21:42:43 +0800 Subject: [PATCH 189/400] chore: add backgroud.navigation.color. update colors in searching page --- Mastodon/Generated/Assets.swift | 1 + .../navigationBar.colorset/Contents.json | 38 +++++++++++++++++ .../Scene/Search/SearchViewController.swift | 18 +++++--- .../SearchViewModel+LoadOldestState.swift | 15 +++---- Mastodon/Scene/Search/SearchViewModel.swift | 42 ++++++++++--------- .../TableViewCell/SearchBottomLoader.swift | 2 +- .../SearchingTableViewCell.swift | 2 +- .../SearchRecommendCollectionHeader.swift | 2 +- .../MastodonSDK/API/Mastodon+API+Search.swift | 11 +++-- 9 files changed, 92 insertions(+), 39 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 71034c1d8..c8fccbfef 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -42,6 +42,7 @@ internal enum Asset { internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") + internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json new file mode 100644 index 000000000..7f9578a7a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/navigationBar.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.940", + "blue" : "249", + "green" : "249", + "red" : "249" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.940", + "blue" : "29", + "green" : "29", + "red" : "29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 2e6fc651c..470c88e0d 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -27,6 +27,7 @@ final class SearchViewController: UIViewController, NeedsDependency { searchBar.showsBookmarkButton = true searchBar.showsScopeBar = false searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags] + searchBar.barTintColor = Asset.Colors.Background.navigationBar.color return searchBar }() @@ -76,9 +77,10 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) return tableView }() @@ -99,7 +101,7 @@ final class SearchViewController: UIViewController, NeedsDependency { let clearSearchHistoryButton: UIButton = { let button = UIButton(type: .custom) - button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal) return button }() @@ -109,6 +111,12 @@ extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithTransparentBackground() + barAppearance.backgroundColor = Asset.Colors.Background.navigationBar.color + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance searchBar.delegate = self navigationItem.titleView = searchBar navigationItem.hidesBackButton = true @@ -183,11 +191,11 @@ extension SearchViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { switch selectedScope { case 0: - viewModel.searchScope.value = "" + viewModel.searchScope.value = Mastodon.API.Search.SearchType.default case 1: - viewModel.searchScope.value = Mastodon.API.Search.Scope.accounts.rawValue + viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts case 2: - viewModel.searchScope.value = Mastodon.API.Search.Scope.hashtags.rawValue + viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags default: break } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index 6306e2e6c..7088f1360 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -53,23 +53,24 @@ extension SearchViewModel.LoadOldestState { } var offset = 0 switch viewModel.searchScope.value { - case Mastodon.API.Search.Scope.accounts.rawValue: + case Mastodon.API.Search.SearchType.accounts: offset = oldSearchResult.accounts.count - case Mastodon.API.Search.Scope.hashtags.rawValue: + case Mastodon.API.Search.SearchType.hashtags: offset = oldSearchResult.hashtags.count default: return } - let query = Mastodon.API.Search.Query(accountID: nil, + let query = Mastodon.API.Search.Query(q: viewModel.searchText.value, + type: viewModel.searchScope.value, + accountID: nil, maxID: nil, minID: nil, - type: viewModel.searchScope.value, excludeUnreviewed: nil, - q: viewModel.searchText.value, resolve: nil, limit: nil, offset: offset, following: nil) + viewModel.context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { @@ -81,7 +82,7 @@ extension SearchViewModel.LoadOldestState { } } receiveValue: { result in switch viewModel.searchScope.value { - case Mastodon.API.Search.Scope.accounts.rawValue: + case Mastodon.API.Search.SearchType.accounts: if result.value.accounts.isEmpty { stateMachine.enter(NoMore.self) } else { @@ -91,7 +92,7 @@ extension SearchViewModel.LoadOldestState { viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) stateMachine.enter(Idle.self) } - case Mastodon.API.Search.Scope.hashtags.rawValue: + case Mastodon.API.Search.SearchType.hashtags: if result.value.hashtags.isEmpty { stateMachine.enter(NoMore.self) } else { diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index df13ac48c..06b654b3d 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -22,7 +22,7 @@ final class SearchViewModel: NSObject { // output let searchText = CurrentValueSubject("") - let searchScope = CurrentValueSubject("") + let searchScope = CurrentValueSubject(Mastodon.API.Search.SearchType.default) let isSearching = CurrentValueSubject(false) @@ -68,12 +68,12 @@ final class SearchViewModel: NSObject { } .flatMap { (text, scope) -> AnyPublisher, Error> in - let query = Mastodon.API.Search.Query(accountID: nil, + let query = Mastodon.API.Search.Query(q: text, + type: scope, + accountID: nil, maxID: nil, minID: nil, - type: scope, excludeUnreviewed: nil, - q: text, resolve: nil, limit: nil, offset: nil, @@ -101,25 +101,27 @@ final class SearchViewModel: NSObject { searchScope ) .filter { isSearching, text, _ in - isSearching && text.isEmpty + isSearching } - .sink { [weak self] _, _, scope in + .sink { [weak self] _, text, scope in guard let self = self else { return } guard let searchHistories = self.fetchSearchHistory() else { return } guard let dataSource = self.searchResultDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.mixed]) - - searchHistories.forEach { searchHistory in - let containsAccount = scope == Mastodon.API.Search.Scope.accounts.rawValue || scope == "" - let containsHashTag = scope == Mastodon.API.Search.Scope.hashtags.rawValue || scope == "" - if let mastodonUser = searchHistory.account, containsAccount { - let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) - snapshot.appendItems([item], toSection: .mixed) - } - if let tag = searchHistory.hashtag, containsHashTag { - let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) - snapshot.appendItems([item], toSection: .mixed) + if text.isEmpty { + snapshot.appendSections([.mixed]) + + searchHistories.forEach { searchHistory in + let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default + let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default + if let mastodonUser = searchHistory.account, containsAccount { + let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + if let tag = searchHistory.hashtag, containsHashTag { + let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) + snapshot.appendItems([item], toSection: .mixed) + } } } dataSource.apply(snapshot, animatingDifferences: false, completion: nil) @@ -166,7 +168,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.account]) let items = accounts.compactMap { SearchResultItem.account(account: $0) } snapshot.appendItems(items, toSection: .account) - if self.searchScope.value == Mastodon.API.Search.Scope.accounts.rawValue && !items.isEmpty { + if self.searchScope.value == Mastodon.API.Search.SearchType.accounts && !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .account) } } @@ -174,7 +176,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.hashtag]) let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) } snapshot.appendItems(items, toSection: .hashtag) - if self.searchScope.value == Mastodon.API.Search.Scope.hashtags.rawValue && !items.isEmpty { + if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags && !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .hashtag) } } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift index dcd4c4971..7ab18bb0c 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift @@ -40,7 +40,7 @@ final class SearchBottomLoader: UITableViewCell { func _init() { selectionStyle = .none - backgroundColor = Asset.Colors.lightWhite.color + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(activityIndicatorView) activityIndicatorView.constrainToCenter() } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index cdfcdce23..aab8a5706 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -20,7 +20,7 @@ final class SearchingTableViewCell: UITableViewCell { let _titleLabel: UILabel = { let label = UILabel() - label.textColor = Asset.Colors.buttonDefault.color + label.textColor = Asset.Colors.brandBlue.color label.font = .systemFont(ofSize: 17, weight: .semibold) label.lineBreakMode = .byTruncatingTail return label diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index df876a635..216f18f98 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -28,7 +28,7 @@ class SearchRecommendCollectionHeader: UIView { let seeAllButton: UIButton = { let button = UIButton(type: .custom) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.setTitle(L10n.Scene.Search.Recommend.buttontext, for: .normal) + button.setTitle(L10n.Scene.Search.Recommend.buttonText, for: .normal) return button }() diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift index dc6ef71e7..be8bb2607 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift @@ -50,9 +50,6 @@ extension Mastodon.API.Search { } extension Mastodon.API.Search { - public enum SearchType: String, Codable { - case ccounts, hashtags, statuses - } public struct Query: Codable, GetQuery { public init(q: String, @@ -109,9 +106,11 @@ extension Mastodon.API.Search { } public extension Mastodon.API.Search { - enum Scope: String { + enum SearchType: String, Codable { case accounts case hashtags + case statuses + case `default` public var rawValue: String { switch self { @@ -119,6 +118,10 @@ public extension Mastodon.API.Search { return "accounts" case .hashtags: return "hashtags" + case .statuses: + return "statuses" + case .default: + return "" } } } From 0dab9acd91bbab0b1bf4352e26adb9e325eb6429 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 12:22:05 +0800 Subject: [PATCH 190/400] fix: tag and searchHistory repeated save in CoreDate --- .../CoreData.xcdatamodel/contents | 32 ++++----- CoreDataStack/Entity/History.swift | 20 ++++++ CoreDataStack/Entity/SearchHistory.swift | 18 ++++- CoreDataStack/Entity/Tag.swift | 60 +++++++++++++---- Mastodon.xcodeproj/project.pbxproj | 8 +-- Mastodon/Extension/Array.swift | 20 ------ ...earchRecommendTagsCollectionViewCell.swift | 3 +- .../SearchViewModel+LoadOldestState.swift | 6 +- Mastodon/Scene/Search/SearchViewModel.swift | 53 +++++++++++++-- .../SearchingTableViewCell.swift | 4 +- .../CoreData/APIService+CoreData+Tag.swift | 66 +++++++++++++++++++ 11 files changed, 225 insertions(+), 65 deletions(-) delete mode 100644 Mastodon/Extension/Array.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 24a3955e7..f635a3db0 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -149,6 +149,7 @@ + @@ -194,24 +195,25 @@ + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/History.swift b/CoreDataStack/Entity/History.swift index 114879298..6fe703e84 100644 --- a/CoreDataStack/Entity/History.swift +++ b/CoreDataStack/Entity/History.swift @@ -40,6 +40,26 @@ public extension History { } } +public extension History { + func update(day: Date) { + if self.day != day { + self.day = day + } + } + + func update(uses: String) { + if self.uses != uses { + self.uses = uses + } + } + + func update(accounts: String) { + if self.accounts != accounts { + self.accounts = accounts + } + } +} + public extension History { struct Property { public let day: Date diff --git a/CoreDataStack/Entity/SearchHistory.swift b/CoreDataStack/Entity/SearchHistory.swift index 33b8a6010..d924917ee 100644 --- a/CoreDataStack/Entity/SearchHistory.swift +++ b/CoreDataStack/Entity/SearchHistory.swift @@ -12,6 +12,7 @@ public final class SearchHistory: NSManagedObject { public typealias ID = UUID @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var createAt: Date + @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var account: MastodonUser? @NSManaged public private(set) var hashtag: Tag? @@ -22,6 +23,13 @@ extension SearchHistory { public override func awakeFromInsert() { super.awakeFromInsert() setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier)) + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.createAt)) + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) + } + + public override func willSave() { + super.willSave() + setPrimitiveValue(Date(), forKey: #keyPath(SearchHistory.updatedAt)) } @discardableResult @@ -31,7 +39,6 @@ extension SearchHistory { ) -> SearchHistory { let searchHistory: SearchHistory = context.insertObject() searchHistory.account = account - searchHistory.createAt = Date() return searchHistory } @@ -42,13 +49,18 @@ extension SearchHistory { ) -> SearchHistory { let searchHistory: SearchHistory = context.insertObject() searchHistory.hashtag = hashtag - searchHistory.createAt = Date() return searchHistory } } +public extension SearchHistory { + func update(updatedAt: Date) { + setValue(updatedAt, forKey: #keyPath(SearchHistory.updatedAt)) + } +} + extension SearchHistory: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \SearchHistory.createAt, ascending: false)] + return [NSSortDescriptor(keyPath: \SearchHistory.updatedAt, ascending: false)] } } diff --git a/CoreDataStack/Entity/Tag.swift b/CoreDataStack/Entity/Tag.swift index 2f1914c4a..3044cacc0 100644 --- a/CoreDataStack/Entity/Tag.swift +++ b/CoreDataStack/Entity/Tag.swift @@ -12,25 +12,33 @@ public final class Tag: NSManagedObject { public typealias ID = UUID @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var createAt: Date - + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var name: String @NSManaged public private(set) var url: String - + // many-to-many relationship @NSManaged public private(set) var statuses: Set? - + // one-to-many relationship @NSManaged public private(set) var histories: Set? } -extension Tag { - public override func awakeFromInsert() { +public extension Tag { + override func awakeFromInsert() { super.awakeFromInsert() setPrimitiveValue(UUID(), forKey: #keyPath(Tag.identifier)) + setPrimitiveValue(Date(), forKey: #keyPath(Tag.createAt)) + setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) } - + + override func willSave() { + super.willSave() + setPrimitiveValue(Date(), forKey: #keyPath(Tag.updatedAt)) + } + @discardableResult - public static func insert( + static func insert( into context: NSManagedObjectContext, property: Property ) -> Tag { @@ -44,8 +52,8 @@ extension Tag { } } -extension Tag { - public struct Property { +public extension Tag { + struct Property { public let name: String public let url: String public let histories: [History]? @@ -58,8 +66,36 @@ extension Tag { } } -extension Tag: Managed { - public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] +public extension Tag { + func updateHistory(index: Int, day: Date, uses: String, account: String) { + guard let histories = self.histories?.sorted(by: { + $0.createAt.compare($1.createAt) == .orderedAscending + }) else { return } + let history = histories[index] + history.update(day: day) + history.update(uses: uses) + history.update(accounts: account) + } + + func appendHistory(history: History) { + self.mutableSetValue(forKeyPath: #keyPath(Tag.histories)).add(history) + } + + func update(url: String) { + if self.url != url { + self.url = url + } + } +} + +extension Tag: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + [NSSortDescriptor(keyPath: \Tag.createAt, ascending: false)] + } +} + +public extension Tag { + static func predicate(name: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(Tag.name), name) } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 97b65eeb1..d002ccd1a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,7 +30,6 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; - 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; @@ -92,6 +91,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; @@ -401,7 +401,6 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; - 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -460,6 +459,7 @@ 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; @@ -1321,6 +1321,7 @@ 2D69D00925CAA00300C3A1B2 /* APIService+CoreData+Status.swift */, DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, + 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, ); path = CoreData; sourceTree = ""; @@ -1550,7 +1551,6 @@ 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */, DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */, DB0140CE25C42AEE00F9F3CF /* OSLog.swift */, - 2D0B7A0A261D5A5600B44727 /* Array.swift */, DB68A06225E905E000CFDF14 /* UIApplication.swift */, DB45FAB525CA5485005A8AC7 /* UIAlertController.swift */, 2D42FF8E25C8228A004A627A /* UIButton.swift */, @@ -2207,6 +2207,7 @@ 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, + 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2284,7 +2285,6 @@ 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, - 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift deleted file mode 100644 index 91c2e3d66..000000000 --- a/Mastodon/Extension/Array.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Array.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/7. -// - -import Foundation - -public extension Array where Element: Equatable { - - func removeDuplicate() -> Array { - return self.enumerated().filter { (index,value) -> Bool in - return self.firstIndex(of: value) == index - }.map { (_, value) in - value - } - } -} - diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 08090f804..f7ff5f33e 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -82,7 +82,8 @@ extension SearchRecommendTagsCollectionViewCell { peopleLabel.text = "" return } - let recentHistory = historys[0...2] + + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap({ Int($0.accounts) }).reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) peopleLabel.text = string diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index 7088f1360..c76ab202c 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -89,7 +89,8 @@ extension SearchViewModel.LoadOldestState { var newAccounts = [Mastodon.Entity.Account]() newAccounts.append(contentsOf: oldSearchResult.accounts) newAccounts.append(contentsOf: result.value.accounts) - viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts.removeDuplicate(), statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) + newAccounts.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) stateMachine.enter(Idle.self) } case Mastodon.API.Search.SearchType.hashtags: @@ -99,7 +100,8 @@ extension SearchViewModel.LoadOldestState { var newTags = [Mastodon.Entity.Tag]() newTags.append(contentsOf: oldSearchResult.hashtags) newTags.append(contentsOf: result.value.hashtags) - viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags.removeDuplicate()) + newTags.removeDuplicates() + viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: oldSearchResult.accounts, statuses: oldSearchResult.statuses, hashtags: newTags) stateMachine.enter(Idle.self) } default: diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 06b654b3d..18954665c 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -234,6 +234,7 @@ final class SearchViewModel: NSObject { } func saveItemToCoreData(item: SearchResultItem) { + let searchHistories = self.fetchSearchHistory() _ = context.managedObjectContext.performChanges { [weak self] in guard let self = self else { return } switch item { @@ -255,15 +256,55 @@ final class SearchViewModel: NSObject { } }() let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) - SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == mastodonUser.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) + } case .hashtag(let tag): - let histories = tag.history?[0 ... 2].compactMap { history -> History in - History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == tagInCoreData.objectID + } + if let history = history { + history.update(updatedAt: Date()) + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + } else { + SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) + } + case .accountObjectID(let accountObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let account = history.account else { return false } + return account.objectID == accountObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } + } + case .hashtagObjectID(let hashtagObjectID): + if let searchHistories = searchHistories { + let history = searchHistories.first { history -> Bool in + guard let hashtag = history.hashtag else { return false } + return hashtag.objectID == hashtagObjectID + } + if let history = history { + history.update(updatedAt: Date()) + } } - let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories)) - SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) - default: break } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index aab8a5706..9fe0f1336 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -96,7 +96,7 @@ extension SearchingTableViewCell { _subTitleLabel.text = "" return } - let recentHistory = historys[0 ... 2] + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string @@ -112,7 +112,7 @@ extension SearchingTableViewCell { _subTitleLabel.text = "" return } - let recentHistory = historys[0 ... 2] + let recentHistory = historys.prefix(2) let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) _subTitleLabel.text = string diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift new file mode 100644 index 000000000..3f931ddea --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift @@ -0,0 +1,66 @@ +// +// APIService+CoreData+Tag.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/8. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK + +extension APIService.CoreData { + static func createOrMergeTag( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Tag + ) -> (Tag: Tag, isCreated: Bool) { + // fetch old mastodon user + let oldTag: Tag? = { + let request = Tag.sortedFetchRequest + request.predicate = Tag.predicate(name: entity.name) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldTag = oldTag { + APIService.CoreData.merge(tag: oldTag, entity: entity, into: managedObjectContext) + return (oldTag, false) + } else { + let histories = entity.history?.prefix(2).compactMap { history -> History in + History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) + } + let tagInCoreData = Tag.insert(into: managedObjectContext, property: Tag.Property(name: entity.name, url: entity.url, histories: histories)) + return (tagInCoreData, true) + } + } + + static func merge(tag:Tag,entity:Mastodon.Entity.Tag,into managedObjectContext: NSManagedObjectContext) { + tag.update(url: tag.url) + guard let tagHistories = tag.histories else { return } + guard let entityHistories = entity.history?.prefix(2) else { return } + let entityHistoriesCount = entityHistories.count + if entityHistoriesCount == 0 { + return + } + for n in 0.. Date: Thu, 8 Apr 2021 12:31:48 +0800 Subject: [PATCH 191/400] chore: add navigation to hashtagViewController --- .../Scene/Search/SearchViewController+Searching.swift | 2 +- Mastodon/Scene/Search/SearchViewController.swift | 2 +- Mastodon/Scene/Search/SearchViewModel.swift | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 34acf443f..540fd20c3 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -86,6 +86,6 @@ extension SearchViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - viewModel.saveItemToCoreData(item: item) + viewModel.searchResultItemDidSelected(item: item, from: self) } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 470c88e0d..d506ab993 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -15,7 +15,7 @@ final class SearchViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - private(set) lazy var viewModel = SearchViewModel(context: context) + private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator) let searchBar: UISearchBar = { let searchBar = UISearchBar() diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 18954665c..054ac8ce3 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -19,6 +19,7 @@ final class SearchViewModel: NSObject { // input let context: AppContext + weak var coordinator: SceneCoordinator! // output let searchText = CurrentValueSubject("") @@ -51,7 +52,8 @@ final class SearchViewModel: NSObject { lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) - init(context: AppContext) { + init(context: AppContext,coordinator: SceneCoordinator) { + self.coordinator = coordinator self.context = context super.init() @@ -233,7 +235,7 @@ final class SearchViewModel: NSObject { } } - func saveItemToCoreData(item: SearchResultItem) { + func searchResultItemDidSelected(item: SearchResultItem,from: UIViewController) { let searchHistories = self.fetchSearchHistory() _ = context.managedObjectContext.performChanges { [weak self] in guard let self = self else { return } @@ -285,6 +287,8 @@ final class SearchViewModel: NSObject { } else { SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) } + let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) case .accountObjectID(let accountObjectID): if let searchHistories = searchHistories { let history = searchHistories.first { history -> Bool in @@ -305,6 +309,9 @@ final class SearchViewModel: NSObject { history.update(updatedAt: Date()) } } + let tagInCoreData = self.context.managedObjectContext.object(with: hashtagObjectID) as! Tag + let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) default: break } From 803ff3a7fd3e6bcadb682c675e1b268ea83b8492 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 12:47:13 +0800 Subject: [PATCH 192/400] chore: add navigation to profile, add recommend navigation --- .../SearchViewController+Recomend.swift | 14 +++++ Mastodon/Scene/Search/SearchViewModel.swift | 52 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController+Recomend.swift b/Mastodon/Scene/Search/SearchViewController+Recomend.swift index 87f32261c..5eacd2d80 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recomend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recomend.swift @@ -5,6 +5,8 @@ // Created by sxiaojian on 2021/3/31. // +import CoreData +import CoreDataStack import Foundation import MastodonSDK import OSLog @@ -54,6 +56,18 @@ extension SearchViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: indexPath: %s", (#file as NSString).lastPathComponent, #line, #function, indexPath.debugDescription) collectionView.selectItem(at: indexPath, animated: true, scrollPosition: .centeredHorizontally) + switch collectionView { + case self.accountsCollectionView: + guard let diffableDataSource = viewModel.accountDiffableDataSource else { return } + guard let account = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.accountCollectionViewItemDidSelected(account: account, from: self) + case self.hashtagCollectionView: + guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return } + guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return } + viewModel.hashtagCollectionViewItemDidSelected(hashtag: hashtag, from: self) + default: + break + } } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 054ac8ce3..46255fde1 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -235,6 +235,41 @@ final class SearchViewModel: NSObject { } } + func accountCollectionViewItemDidSelected(account: Mastodon.Entity.Account, from: UIViewController) { + _ = context.managedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + // load request mastodon user + let requestMastodonUser: MastodonUser? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.context.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } + } + } + + func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) { + let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: hashtag) + let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + DispatchQueue.main.async { + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + } + } + func searchResultItemDidSelected(item: SearchResultItem,from: UIViewController) { let searchHistories = self.fetchSearchHistory() _ = context.managedObjectContext.performChanges { [weak self] in @@ -271,6 +306,10 @@ final class SearchViewModel: NSObject { } else { SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser) } + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } case .hashtag(let tag): let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) @@ -288,7 +327,9 @@ final class SearchViewModel: NSObject { SearchHistory.insert(into: self.context.managedObjectContext, hashtag: tagInCoreData) } let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) - self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + DispatchQueue.main.async { + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + } case .accountObjectID(let accountObjectID): if let searchHistories = searchHistories { let history = searchHistories.first { history -> Bool in @@ -299,6 +340,11 @@ final class SearchViewModel: NSObject { history.update(updatedAt: Date()) } } + let mastodonUser = self.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) + } case .hashtagObjectID(let hashtagObjectID): if let searchHistories = searchHistories { let history = searchHistories.first { history -> Bool in @@ -311,7 +357,9 @@ final class SearchViewModel: NSObject { } let tagInCoreData = self.context.managedObjectContext.object(with: hashtagObjectID) as! Tag let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) - self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + DispatchQueue.main.async { + self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) + } default: break } From 4ffea58f711ba0d8c419e44d6169dfb2662c9038 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 13:30:32 +0800 Subject: [PATCH 193/400] fix: SearchCard UI update, change follow button border width, add card border when DarkMode, add shadow --- Mastodon/Generated/Assets.swift | 6 +++ .../Colors/Border/Contents.json | 9 +++++ .../Border/searchCard.colorset/Contents.json | 38 +++++++++++++++++++ .../Colors/Shadow/Contents.json | 9 +++++ .../Shadow/SearchCard.colorset/Contents.json | 38 +++++++++++++++++++ ...hRecommendAccountsCollectionViewCell.swift | 31 +++++++++++---- ...earchRecommendTagsCollectionViewCell.swift | 16 ++++++-- 7 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index c8fccbfef..eb69bda10 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -51,6 +51,9 @@ internal enum Asset { internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") } + internal enum Border { + internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard") + } internal enum Button { internal static let actionToolbar = ColorAsset(name: "Colors/Button/action.toolbar") internal static let disabled = ColorAsset(name: "Colors/Button/disabled") @@ -66,6 +69,9 @@ internal enum Asset { internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } + internal enum Shadow { + internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") + } internal enum Slider { internal static let bar = ColorAsset(name: "Colors/Slider/bar") } diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json new file mode 100644 index 000000000..a0ce2efb8 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/searchCard.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "213", + "green" : "213", + "red" : "213" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json new file mode 100644 index 000000000..a28cf0793 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Shadow/SearchCard.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "0", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index b4305eeff..b6eafb3f9 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -12,18 +12,23 @@ import UIKit class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let avatarImageView: UIImageView = { let imageView = UIImageView() - imageView.layer.cornerRadius = 8 + imageView.layer.cornerRadius = 8.4 imageView.clipsToBounds = true return imageView }() let headerImageView: UIImageView = { let imageView = UIImageView() - imageView.layer.cornerRadius = 8 + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 10 imageView.clipsToBounds = true + imageView.layer.borderWidth = 2 + imageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor return imageView }() + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + let displayNameLabel: UILabel = { let label = UILabel() label.textColor = .white @@ -46,7 +51,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) button.layer.cornerRadius = 12 - button.layer.borderWidth = 3 + button.layer.borderWidth = 2 button.layer.borderColor = UIColor.white.cgColor return button }() @@ -55,6 +60,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { super.prepareForReuse() headerImageView.af.cancelImageRequest() avatarImageView.af.cancelImageRequest() + visualEffectView.removeFromSuperview() } override init(frame: CGRect) { @@ -69,11 +75,17 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { } extension SearchRecommendAccountsCollectionViewCell { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor + applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) + } + private func configure() { headerImageView.backgroundColor = Asset.Colors.brandBlue.color - layer.cornerRadius = 8 - clipsToBounds = true - + layer.cornerRadius = 10 + clipsToBounds = false + applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) contentView.addSubview(headerImageView) headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0) @@ -115,8 +127,11 @@ extension SearchRecommendAccountsCollectionViewCell { headerImageView.af.setImage( withURL: URL(string: account.header)!, placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) + imageTransition: .crossDissolve(0.2)) { [weak self] _ in + guard let self = self else { return } + self.headerImageView.addSubview(self.visualEffectView) + self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0) + } } } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index f7ff5f33e..813c8a34f 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -15,7 +15,7 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() - + let hashtagTitleLabel: UILabel = { let label = UILabel() label.textColor = .white @@ -58,10 +58,20 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { } extension SearchRecommendTagsCollectionViewCell { + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor + applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) + } + private func configure() { backgroundColor = Asset.Colors.brandBlue.color - layer.cornerRadius = 8 - clipsToBounds = true + layer.cornerRadius = 10 + clipsToBounds = false + layer.borderWidth = 2 + layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor + applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) contentView.addSubview(backgroundImageView) backgroundImageView.constrain(toSuperviewEdges: nil) From cc4290385d8c57c8a07195d982e902c4799b977b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 14:01:22 +0800 Subject: [PATCH 194/400] chore: rename recommend --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- ...ecomend.swift => SearchViewController+Recommend.swift} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename Mastodon/Scene/Search/{SearchViewController+Recomend.swift => SearchViewController+Recommend.swift} (99%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d002ccd1a..01d5f1f20 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -45,7 +45,7 @@ 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; - 2D34D9CB261489930081BFC0 /* SearchViewController+Recomend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */; }; + 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */; }; 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; @@ -416,7 +416,7 @@ 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recomend.swift"; sourceTree = ""; }; + 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Recommend.swift"; sourceTree = ""; }; 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; @@ -1611,7 +1611,7 @@ 2DFAD5212616F8E300F9EE7C /* TableViewCell */, 2DE0FAC62615F5D200CDF649 /* View */, DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, - 2D34D9CA261489930081BFC0 /* SearchViewController+Recomend.swift */, + 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */, 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */, @@ -2311,7 +2311,7 @@ DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, - 2D34D9CB261489930081BFC0 /* SearchViewController+Recomend.swift in Sources */, + 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, diff --git a/Mastodon/Scene/Search/SearchViewController+Recomend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift similarity index 99% rename from Mastodon/Scene/Search/SearchViewController+Recomend.swift rename to Mastodon/Scene/Search/SearchViewController+Recommend.swift index 5eacd2d80..056df2e38 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recomend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -1,5 +1,5 @@ // -// SearchViewController+Recomend.swift +// SearchViewController+Recommend.swift // Mastodon // // Created by sxiaojian on 2021/3/31. From c1971438cdf0a493b0d5619d09d1ab87f25a6867 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 14:17:57 +0800 Subject: [PATCH 195/400] fix: acctLabel display beyound card --- .../SearchRecommendAccountsCollectionViewCell.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index b6eafb3f9..cda517f43 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -41,6 +41,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .preferredFont(forTextStyle: .body) + label.textAlignment = .center label.translatesAutoresizingMaskIntoConstraints = false return label }() @@ -105,6 +106,8 @@ extension SearchRecommendAccountsCollectionViewCell { contentView.addSubview(acctLabel) acctLabel.constrain([ acctLabel.constraint(.top, toView: contentView, constant: 132), + acctLabel.constraint(.leading, toView: contentView), + acctLabel.constraint(.trailing, toView: contentView), acctLabel.constraint(.centerX, toView: contentView) ]) From 5c7a13e6b31aa295bad03942f6779031b3a6dd23 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 14:19:59 +0800 Subject: [PATCH 196/400] fix: displayNameLabel display beyound card --- .../SearchRecommendAccountsCollectionViewCell.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index cda517f43..626a2b4b6 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -32,6 +32,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let displayNameLabel: UILabel = { let label = UILabel() label.textColor = .white + label.textAlignment = .center label.font = .systemFont(ofSize: 18, weight: .semibold) label.translatesAutoresizingMaskIntoConstraints = false return label @@ -100,6 +101,8 @@ extension SearchRecommendAccountsCollectionViewCell { contentView.addSubview(displayNameLabel) displayNameLabel.constrain([ displayNameLabel.constraint(.top, toView: contentView, constant: 108), + displayNameLabel.constraint(.leading, toView: contentView), + displayNameLabel.constraint(.trailing, toView: contentView), displayNameLabel.constraint(.centerX, toView: contentView) ]) From ae20a290136b8b250d684c3f92a04f1213add37f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 8 Apr 2021 16:12:04 +0800 Subject: [PATCH 197/400] fix: make seeAll button and clear button highlight when user tapping --- Mastodon/Scene/Search/SearchViewController.swift | 4 ++-- .../Scene/Search/View/SearchRecommendCollectionHeader.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index d506ab993..b98a34b95 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -99,8 +99,8 @@ final class SearchViewController: UIViewController, NeedsDependency { return label }() - let clearSearchHistoryButton: UIButton = { - let button = UIButton(type: .custom) + let clearSearchHistoryButton: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal) return button diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index 216f18f98..bc5bd7663 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -25,8 +25,8 @@ class SearchRecommendCollectionHeader: UIView { return label }() - let seeAllButton: UIButton = { - let button = UIButton(type: .custom) + let seeAllButton: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) button.setTitle(L10n.Scene.Search.Recommend.buttonText, for: .normal) return button From b6269c76433875b3fc750d279fd68a739a968d48 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 14:24:28 +0800 Subject: [PATCH 198/400] feat: add favorite scene --- Mastodon.xcodeproj/project.pbxproj | 32 +++ .../xcschemes/xcschememanagement.plist | 2 +- Mastodon/Coordinator/SceneCoordinator.swift | 5 + .../StatusProvider/StatusProviderFacade.swift | 4 +- .../StatusTableViewControllerAspect.swift | 81 ++++++++ ...ableViewCellHeightCacheableContainer.swift | 28 ++- ...avoriteViewController+StatusProvider.swift | 87 ++++++++ .../Favorite/FavoriteViewController.swift | 138 +++++++++++++ .../Favorite/FavoriteViewModel+Diffable.swift | 39 ++++ .../Favorite/FavoriteViewModel+State.swift | 164 +++++++++++++++ .../Profile/Favorite/FavoriteViewModel.swift | 101 ++++++++++ .../Scene/Profile/MeProfileViewModel.swift | 2 +- .../Scene/Profile/ProfileViewController.swift | 54 ++++- Mastodon/Scene/Profile/ProfileViewModel.swift | 4 + .../Timeline/UserTimelineViewController.swift | 43 ++-- .../UserTimelineViewModel+State.swift | 77 ++------ .../Timeline/UserTimelineViewModel.swift | 12 +- .../APIService/APIService+Favorite.swift | 16 +- .../Persist/APIService+Persist+Status.swift | 4 +- .../API/Mastodon+API+Favorites.swift | 186 ++++++++++-------- 20 files changed, 885 insertions(+), 194 deletions(-) create mode 100644 Mastodon/Protocol/StatusTableViewControllerAspect.swift create mode 100644 Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift create mode 100644 Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift create mode 100644 Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 316cb4104..2f3edabf7 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -313,6 +313,12 @@ DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */; }; DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */; }; + DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */; }; + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; }; + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; }; + DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; + DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -684,6 +690,12 @@ DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderView.swift; sourceTree = ""; }; + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; }; + DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; + DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = ""; }; + DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; + DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; + DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; @@ -971,6 +983,7 @@ 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */, 2D38F20725CD491300561493 /* DisposeBagCollectable.swift */, 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */, + DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */, ); path = Protocol; sourceTree = ""; @@ -1602,6 +1615,7 @@ DBB525132611EBB1002F1F29 /* Segmented */, DBB525462611ED57002F1F29 /* Header */, DBB5253B2611ECF5002F1F29 /* Timeline */, + DBE3CDF1261C6B3100430CC6 /* Favorite */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, @@ -1739,6 +1753,18 @@ path = Register; sourceTree = ""; }; + DBE3CDF1261C6B3100430CC6 /* Favorite */ = { + isa = PBXGroup; + children = ( + DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */, + DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */, + DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */, + DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */, + DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */, + ); + path = Favorite; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -2158,6 +2184,7 @@ 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, + DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, @@ -2172,6 +2199,7 @@ 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, + DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2181,15 +2209,18 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, DB9D6C2425E502C60051B173 /* MosaicImageViewModel.swift in Sources */, + DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */, 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */, DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, + DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, @@ -2353,6 +2384,7 @@ DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, + DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index c1f4dcf1f..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 12 + 10 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c76c8ba47..6ad34ec30 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -56,6 +56,7 @@ extension SceneCoordinator { // profile case profile(viewModel: ProfileViewModel) + case favorite(viewModel: FavoriteViewModel) // misc case alertController(alertController: UIAlertController) @@ -232,6 +233,10 @@ private extension SceneCoordinator { let _viewController = ProfileViewController() _viewController.viewModel = viewModel viewController = _viewController + case .favorite(let viewModel): + let _viewController = FavoriteViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index d1c24c97f..abdc27902 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -173,7 +173,7 @@ extension StatusProviderFacade { return (status.objectID, favoriteKind) } .map { statusObjectID, favoriteKind -> AnyPublisher<(Status.ID, Mastodon.API.Favorites.FavoriteKind), Error> in - return context.apiService.like( + return context.apiService.favorite( statusObjectID: statusObjectID, mastodonUserObjectID: mastodonUserObjectID, favoriteKind: favoriteKind @@ -201,7 +201,7 @@ extension StatusProviderFacade { } } .map { statusID, favoriteKind in - return context.apiService.like( + return context.apiService.favorite( statusID: statusID, favoriteKind: favoriteKind, mastodonAuthenticationBox: activeMastodonAuthenticationBox diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift new file mode 100644 index 000000000..5fb36dc6f --- /dev/null +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -0,0 +1,81 @@ +// +// StatusTableViewControllerAspect.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit +import AVKit + +protocol StatusTableViewControllerAspect: UIViewController { + var tableView: UITableView { get } +} + +// MARK: - UIViewController + +// StatusTableViewControllerAspect.aspectViewWillAppear(_:) +extension StatusTableViewControllerAspect { + func aspectViewWillAppear(_ animated: Bool) { + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } +} + +extension StatusTableViewControllerAspect where Self: NeedsDependency { + func aspectViewDidDisappear(_ animated: Bool) { + context.videoPlaybackService.viewDidDisappear(from: self) + } +} + +// MARK: - UITableViewDelegate + +// aspectTableView(_:estimatedHeightForRowAt:) +extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer { + func aspectScrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +// aspectTableView(_:estimatedHeightForRowAt:) +extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { + func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + handleTableView(tableView, estimatedHeightForRowAt: indexPath) + } +} + +// StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider { + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider { + func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + (self as StatusTableViewCellDelegate & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + (self as TableViewCellHeightCacheableContainer & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } +} + +// MARK: - AVPlayerViewControllerDelegate & NeedsDependency + +// aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) +extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } +} + +// aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) +extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } +} + diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift index 1b0350086..55071d6d7 100644 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -7,6 +7,30 @@ import UIKit -protocol TableViewCellHeightCacheableContainer: UIViewController { - // TODO: +protocol TableViewCellHeightCacheableContainer: StatusProvider { + var cellFrameCache: NSCache { get } +} + +extension TableViewCellHeightCacheableContainer { + + func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let item = item(for: nil, indexPath: indexPath) else { return } + + let key = item.hashValue + let frame = cell.frame + cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + } + + func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let item = item(for: nil, indexPath: indexPath) else { return 200 } + guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return 200 + } + } + + return ceil(frame.height) + } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift new file mode 100644 index 000000000..2dadc8545 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift @@ -0,0 +1,87 @@ +// +// FavoriteViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension FavoriteViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .status(let objectID, _): + let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift new file mode 100644 index 000000000..1f8a1d332 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -0,0 +1,138 @@ +// +// FavoriteViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +// Note: Prefer use US favorite then EN favourite in coding +// to following the text checker auto-correct behavior + +import os.log +import UIKit +import AVKit +import Combine +import GameplayKit + +final class FavoriteViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: FavoriteViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension FavoriteViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self + ) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) + } + +} + +// MARK: - StatusTableViewControllerAspect +extension FavoriteViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension FavoriteViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache + } +} + +// MARK: - UIScrollViewDelegate +extension FavoriteViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + aspectScrollViewDidScroll(scrollView) + } +} + +// MARK: - UITableViewDelegate +extension FavoriteViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + +} + +// MARK: - AVPlayerViewControllerDelegate +extension FavoriteViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + + +// MARK: - TimelinePostTableViewCellDelegate +extension FavoriteViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - LoadMoreConfigurableTableViewContainer +extension FavoriteViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell + typealias LoadingState = FavoriteViewModel.State.Loading + + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } +} + diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift new file mode 100644 index 000000000..e64df2c99 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -0,0 +1,39 @@ +// +// FavoriteViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import UIKit + +extension FavoriteViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + + stateMachine.enter(State.Reloading.self) + } + +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift new file mode 100644 index 000000000..134adb0a3 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -0,0 +1,164 @@ +// +// FavoriteViewModel+State.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-7. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension FavoriteViewModel { + class State: GKState { + weak var viewModel: FavoriteViewModel? + + init(viewModel: FavoriteViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension FavoriteViewModel.State { + class Initial: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return viewModel.activeMastodonAuthenticationBox.value != nil + default: + return false + } + } + } + + class Reloading: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last + + viewModel.context.apiService.favoritedStatuses( + maxID: maxID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + var hasNewStatusesAppend = false + var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend { + stateMachine.enter(Idle.self) + } else { + stateMachine.enter(NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &viewModel.disposeBag) + } + } + + class NoMore: FavoriteViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } +} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift new file mode 100644 index 000000000..589ffe190 --- /dev/null +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel.swift @@ -0,0 +1,101 @@ +// +// FavoriteViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-6. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit + +final class FavoriteViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let activeMastodonAuthenticationBox: CurrentValueSubject + let statusFetchedResultsController: StatusFetchedResultsController + let cellFrameCache = NSCache() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + + init(context: AppContext) { + self.context = context + self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: Status.notDeleted() + ) + + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + statusFetchedResultsController.objectIDs + .receive(on: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + items.append(.status(objectID: objectID, attribute: attribute)) + } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + break + // TODO: handle other states + default: + break + } + } + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Profile/MeProfileViewModel.swift b/Mastodon/Scene/Profile/MeProfileViewModel.swift index 36bcf0b4e..d1c0cb49d 100644 --- a/Mastodon/Scene/Profile/MeProfileViewModel.swift +++ b/Mastodon/Scene/Profile/MeProfileViewModel.swift @@ -22,7 +22,7 @@ final class MeProfileViewModel: ProfileViewModel { self.currentMastodonUser .sink { [weak self] currentMastodonUser in - os_log("%{public}s[%{public}ld], %{public}s: current active twitter user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") + os_log("%{public}s[%{public}ld], %{public}s: current active mastodon user: %s", ((#file as NSString).lastPathComponent), #line, #function, currentMastodonUser?.username ?? "") guard let self = self else { return } self.mastodonUser.value = currentMastodonUser diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 59cf4809f..c1f11155c 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,6 +18,24 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.and.arrow.up"), style: .plain, target: self, action: #selector(ProfileViewController.shareBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "star"), style: .plain, target: self, action: #selector(ProfileViewController.favoriteBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) barButtonItem.tintColor = .white @@ -118,25 +136,32 @@ extension ProfileViewController { navigationItem.titleView = UIView() - Publishers.CombineLatest( + Publishers.CombineLatest3( + viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + .sink { [weak self] isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in guard let self = self else { return } var items: [UIBarButtonItem] = [] + defer { + self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil + } + + guard isMeBarButtonItemsHidden else { + items.append(self.settingBarButtonItem) + items.append(self.shareBarButtonItem) + items.append(self.favoriteBarButtonItem) + return + } + if !isReplyBarButtonItemHidden { items.append(self.replyBarButtonItem) } if !isMoreMenuBarButtonItemHidden { items.append(self.moreMenuBarButtonItem) } - guard !items.isEmpty else { - self.navigationItem.rightBarButtonItems = nil - return - } - self.navigationItem.rightBarButtonItems = items } .store(in: &disposeBag) @@ -392,6 +417,21 @@ extension ProfileViewController { extension ProfileViewController { + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + } + + @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + let favoriteViewModel = FavoriteViewModel(context: context) + coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) + } + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard let mastodonUser = viewModel.mastodonUser.value else { return } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 057e18030..41fcdcf4c 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -53,6 +53,7 @@ class ProfileViewModel: NSObject { let isRelationshipActionButtonHidden = CurrentValueSubject(true) let isReplyBarButtonItemHidden = CurrentValueSubject(true) let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) + let isMeBarButtonItemsHidden = CurrentValueSubject(true) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context @@ -240,6 +241,7 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = true return } @@ -248,6 +250,7 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = true self.isMoreMenuBarButtonItemHidden.value = true + self.isMeBarButtonItemsHidden.value = false } else { // set with follow action default var relationshipActionSet = RelationshipActionOptionSet([.follow]) @@ -294,6 +297,7 @@ extension ProfileViewModel { // set bar button item state self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy self.isMoreMenuBarButtonItemHidden.value = false + self.isMeBarButtonItemsHidden.value = true } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 442f57cce..4219f5117 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -80,21 +80,31 @@ extension UserTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - tableView.deselectRow(with: transitionCoordinator, animated: animated) + aspectViewWillAppear(animated) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) + aspectViewDidDisappear(animated) } } +// MARK: - StatusTableViewControllerAspect +extension UserTimelineViewController: StatusTableViewControllerAspect { } + // MARK: - UIScrollViewDelegate extension UserTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) + aspectScrollViewDidScroll(scrollView) + } +} + +// MARK: - TableViewCellHeightCacheableContainer +extension UserTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache } } @@ -102,28 +112,11 @@ extension UserTimelineViewController { extension UserTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - - guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - if case .bottomLoader = item { - return TimelineLoaderTableViewCell.cellHeight - } else { - return 200 - } - } - // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - - return ceil(frame.height) + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } - - let key = item.hashValue - let frame = cell.frame - viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } @@ -132,11 +125,11 @@ extension UserTimelineViewController: UITableViewDelegate { extension UserTimelineViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } } @@ -159,7 +152,7 @@ extension UserTimelineViewController: ScrollViewContainer { // MARK: - LoadMoreConfigurableTableViewContainer extension UserTimelineViewController: LoadMoreConfigurableTableViewContainer { typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell - typealias LoadingState = UserTimelineViewModel.State.LoadingMore + typealias LoadingState = UserTimelineViewModel.State.Loading var loadMoreConfigurableTableView: UITableView { return tableView } var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.stateMachine } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index f31f52400..be06d781a 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -40,11 +40,7 @@ extension UserTimelineViewModel.State { class Reloading: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Fail.Type: - return true - case is Idle.Type: - return true - case is NoMore.Type: + case is Loading.Type: return true default: return false @@ -57,69 +53,38 @@ extension UserTimelineViewModel.State { // reset viewModel.statusFetchedResultsController.statusIDs.value = [] - - guard let userID = viewModel.userID.value, !userID.isEmpty else { - stateMachine.enter(Fail.self) - return - } - - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { - stateMachine.enter(Fail.self) - return - } - let domain = activeMastodonAuthenticationBox.domain - let queryFilter = viewModel.queryFilter.value - - viewModel.context.apiService.userTimeline( - domain: domain, - accountID: userID, - maxID: nil, - sinceID: nil, - excludeReplies: queryFilter.excludeReplies, - excludeReblogs: queryFilter.excludeReblogs, - onlyMedia: queryFilter.onlyMedia, - authorizationBox: activeMastodonAuthenticationBox - ) - .receive(on: DispatchQueue.main) - .sink { completion in - - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - - var hasNewStatusesAppend = false - var statusIDs = viewModel.statusFetchedResultsController.statusIDs.value - for status in response.value { - guard !statusIDs.contains(status.id) else { continue } - statusIDs.append(status.id) - hasNewStatusesAppend = true - } - - if hasNewStatusesAppend { - stateMachine.enter(Idle.self) - } else { - stateMachine.enter(NoMore.self) - } - viewModel.statusFetchedResultsController.statusIDs.value = statusIDs - } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Loading.self) } } class Fail: UserTimelineViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type, is LoadingMore.Type: + case is Loading.Type: return true default: return false } } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } } class Idle: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { - case is Reloading.Type, is LoadingMore.Type: + case is Reloading.Type, is Loading.Type: return true default: return false @@ -127,7 +92,7 @@ extension UserTimelineViewModel.State { } } - class LoadingMore: UserTimelineViewModel.State { + class Loading: UserTimelineViewModel.State { override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Fail.Type: @@ -145,10 +110,7 @@ extension UserTimelineViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last else { - stateMachine.enter(Fail.self) - return - } + let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last guard let userID = viewModel.userID.value, !userID.isEmpty else { stateMachine.enter(Fail.self) @@ -177,6 +139,7 @@ extension UserTimelineViewModel.State { switch completion { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + stateMachine.enter(Fail.self) case .finished: break } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 2276db5fe..ac66f037a 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -12,9 +12,8 @@ import Combine import CoreData import CoreDataStack import MastodonSDK -import AlamofireImage -class UserTimelineViewModel: NSObject { +final class UserTimelineViewModel { var disposeBag = Set() @@ -37,7 +36,7 @@ class UserTimelineViewModel: NSObject { State.Reloading(viewModel: self), State.Fail(viewModel: self), State.Idle(viewModel: self), - State.LoadingMore(viewModel: self), + State.Loading(viewModel: self), State.NoMore(viewModel: self), ]) stateMachine.enter(State.Initial.self) @@ -54,7 +53,7 @@ class UserTimelineViewModel: NSObject { self.domain = CurrentValueSubject(domain) self.userID = CurrentValueSubject(userID) self.queryFilter = CurrentValueSubject(queryFilter) - super.init() + // super.init() self.domain .assign(to: \.value, on: statusFetchedResultsController.domain) @@ -105,7 +104,7 @@ class UserTimelineViewModel: NSObject { if let currentState = self.stateMachine.currentState { switch currentState { - case is State.Reloading, is State.LoadingMore, is State.Idle, is State.Fail: + case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: snapshot.appendItems([.bottomLoader], toSection: .main) case is State.NoMore: break @@ -114,8 +113,6 @@ class UserTimelineViewModel: NSObject { break } } - - } .store(in: &disposeBag) } @@ -144,4 +141,3 @@ extension UserTimelineViewModel { } } - diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index d49a7371b..23206494e 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -16,7 +16,7 @@ import CommonOSLog extension APIService { // make local state change only - func like( + func favorite( statusObjectID: NSManagedObjectID, mastodonUserObjectID: NSManagedObjectID, favoriteKind: Mastodon.API.Favorites.FavoriteKind @@ -50,7 +50,7 @@ extension APIService { } // send favorite request to remote - func like( + func favorite( statusID: Mastodon.Entity.Status.ID, favoriteKind: Mastodon.API.Favorites.FavoriteKind, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -128,16 +128,20 @@ extension APIService { } extension APIService { - func likeList( + func favoritedStatuses( limit: Int = onceRequestStatusMaxCount, - userID: String, maxID: String? = nil, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let requestMastodonUserID = mastodonAuthenticationBox.userID - let query = Mastodon.API.Favorites.ListQuery(limit: limit, minID: nil, maxID: maxID) - return Mastodon.API.Favorites.favoritedStatus(domain: mastodonAuthenticationBox.domain, session: session, authorization: mastodonAuthenticationBox.userAuthorization, query: query) + let query = Mastodon.API.Favorites.FavoriteStatusesQuery(limit: limit, minID: nil, maxID: maxID) + return Mastodon.API.Favorites.favoritedStatus( + domain: mastodonAuthenticationBox.domain, + session: session, + authorization: mastodonAuthenticationBox.userAuthorization, + query: query + ) .map { response -> AnyPublisher, Error> in let log = OSLog.api diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index dfd41094a..9bc699b71 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -234,8 +234,8 @@ extension APIService.Persist { let newTweetsInTimeLineCount = persistMemos.reduce(0, { result, next in return next.statusProcessType == .create ? result + 1 : result }) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: tweet: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: twitter user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: status: insert %{public}ldT(%{public}ldTRQ), merge %{public}ldT(%{public}ldTRQ)", ((#file as NSString).lastPathComponent), #line, #function, newTweetsInTimeLineCount, counting.status.create, mergedOldStatusesInTimeline.count, counting.status.merge) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: mastodon user: insert %{public}ld, merge %{public}ld", ((#file as NSString).lastPathComponent), #line, #function, counting.user.create, counting.user.merge) } #endif } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 64598bc14..8f750c7db 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -14,78 +14,6 @@ extension Mastodon.API.Favorites { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("favourites") } - static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { - let pathComponent = "statuses/" + statusID + "/favourited_by" - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { - var actionString: String - switch favoriteKind { - case .create: - actionString = "/favourite" - case .destroy: - actionString = "/unfavourite" - } - let pathComponent = "statuses/" + statusID + actionString - return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) - } - - /// Favourite / Undo Favourite - /// - /// Add a status to your favourites list / Remove a status from your favourites list - /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/3/3 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/statuses/) - /// - Parameters: - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - statusID: Mastodon status id - /// - session: `URLSession` - /// - authorization: User token - /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favorites(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, favoriteKind: FavoriteKind) -> AnyPublisher, Error> { - let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) - var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) - request.httpMethod = "POST" - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - - /// Favourited by - /// - /// View who favourited a given status. - /// - /// - Since: 0.0.0 - /// - Version: 3.3.0 - /// # Last Update - /// 2021/3/3 - /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/statuses/) - /// - Parameters: - /// - domain: Mastodon instance domain. e.g. "example.com" - /// - statusID: Mastodon status id - /// - session: `URLSession` - /// - authorization: User token - /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favoriteBy(domain: String, statusID: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization) -> AnyPublisher, Error> { - let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) - let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) - return session.dataTaskPublisher(for: request) - .tryMap { data, response in - let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) - return Mastodon.Response.Content(value: value, response: response) - } - .eraseToAnyPublisher() - } - /// Favourited statuses /// /// Using this endpoint to view the favourited list for user @@ -101,7 +29,12 @@ extension Mastodon.API.Favorites { /// - session: `URLSession` /// - authorization: User token /// - Returns: `AnyPublisher` contains `Server` nested in the response - public static func favoritedStatus(domain: String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization, query: Mastodon.API.Favorites.ListQuery) -> AnyPublisher, Error> { + public static func favoritedStatus( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.Favorites.FavoriteStatusesQuery + ) -> AnyPublisher, Error> { let url = favoritesStatusesEndpointURL(domain: domain) let request = Mastodon.API.get(url: url, query: query, authorization: authorization) return session.dataTaskPublisher(for: request) @@ -112,16 +45,7 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } -} - -extension Mastodon.API.Favorites { - - public enum FavoriteKind { - case create - case destroy - } - - public struct ListQuery: GetQuery, PagedQueryType { + public struct FavoriteStatusesQuery: GetQuery,TimelineQueryType { public var limit: Int? public var minID: String? @@ -155,3 +79,99 @@ extension Mastodon.API.Favorites { } } + +extension Mastodon.API.Favorites { + + static func favoriteActionEndpointURL(domain: String, statusID: String, favoriteKind: FavoriteKind) -> URL { + var actionString: String + switch favoriteKind { + case .create: + actionString = "/favourite" + case .destroy: + actionString = "/unfavourite" + } + let pathComponent = "statuses/" + statusID + actionString + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourite / Undo Favourite + /// + /// Add a status to your favourites list / Remove a status from your favourites list + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favorites( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + favoriteKind: FavoriteKind + ) -> AnyPublisher, Error> { + let url: URL = favoriteActionEndpointURL(domain: domain, statusID: statusID, favoriteKind: favoriteKind) + var request = Mastodon.API.post(url: url, query: nil, authorization: authorization) + request.httpMethod = "POST" + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public enum FavoriteKind { + case create + case destroy + } + +} + +extension Mastodon.API.Favorites { + + static func favoriteByUserListsEndpointURL(domain: String, statusID: String) -> URL { + let pathComponent = "statuses/" + statusID + "/favourited_by" + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) + } + + /// Favourited by + /// + /// View who favourited a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/3 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: Mastodon status id + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Server` nested in the response + public static func favoriteBy( + domain: String, + statusID: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let url = favoriteByUserListsEndpointURL(domain: domain, statusID: statusID) + let request = Mastodon.API.get(url: url, query: nil, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From c678d432097af3a9b3782d3206932a2970e53e1c Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 16:01:57 +0800 Subject: [PATCH 199/400] chore: add simple document for StatusTableViewControllerAspect --- .../StatusTableViewControllerAspect.swift | 23 +++++++++++++++---- ...ableViewCellHeightCacheableContainer.swift | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index 5fb36dc6f..dfc1a89eb 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -8,20 +8,27 @@ import UIKit import AVKit +/// Status related operations aspect +/// Please check the aspect methods (Option+Click) and add hook to implement features +/// - UI +/// - Media +/// - Data Source protocol StatusTableViewControllerAspect: UIViewController { var tableView: UITableView { get } } // MARK: - UIViewController -// StatusTableViewControllerAspect.aspectViewWillAppear(_:) +// aspectViewWillAppear(_:) extension StatusTableViewControllerAspect { + /// [UI] hook to deselect row in the transitioning for the table view func aspectViewWillAppear(_ animated: Bool) { tableView.deselectRow(with: transitionCoordinator, animated: animated) } } extension StatusTableViewControllerAspect where Self: NeedsDependency { + /// [Media] hook to notify video service func aspectViewDidDisappear(_ animated: Bool) { context.videoPlaybackService.viewDidDisappear(from: self) } @@ -31,6 +38,7 @@ extension StatusTableViewControllerAspect where Self: NeedsDependency { // aspectTableView(_:estimatedHeightForRowAt:) extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer { + /// [Data Source] hook to notify table view bottom loader func aspectScrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) } @@ -38,6 +46,7 @@ extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableV // aspectTableView(_:estimatedHeightForRowAt:) extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { + /// [UI] hook to estimate table view cell height from cache func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { handleTableView(tableView, estimatedHeightForRowAt: indexPath) } @@ -45,21 +54,25 @@ extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheab // StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + /// [Media] hook to notify video service func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer & StatusProvider { + /// [UI] hook to cache table view cell height func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & TableViewCellHeightCacheableContainer & StatusProvider { + /// [Media] hook to notify video service + /// [UI] hook to cache table view cell height func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - (self as StatusTableViewCellDelegate & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) - (self as TableViewCellHeightCacheableContainer & StatusProvider).handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + cacheTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } @@ -67,6 +80,7 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat // aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + /// [Media] hook to mark transitioning to video service func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } @@ -74,6 +88,7 @@ extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDele // aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { + /// [Media] hook to mark transitioning to video service func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift index 55071d6d7..ce5e453cd 100644 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -13,7 +13,7 @@ protocol TableViewCellHeightCacheableContainer: StatusProvider { extension TableViewCellHeightCacheableContainer { - func handleTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let item = item(for: nil, indexPath: indexPath) else { return } let key = item.hashValue From e7279a0ab6cd0fbbcbd8a29e108066cba79e2b31 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 7 Apr 2021 18:01:19 +0800 Subject: [PATCH 200/400] chore: update query type --- .../Sources/MastodonSDK/API/Mastodon+API+Favorites.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift index 8f750c7db..01b9d61a4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Favorites.swift @@ -45,7 +45,7 @@ extension Mastodon.API.Favorites { .eraseToAnyPublisher() } - public struct FavoriteStatusesQuery: GetQuery,TimelineQueryType { + public struct FavoriteStatusesQuery: GetQuery, PagedQueryType { public var limit: Int? public var minID: String? From a0a636917f9bcb932c8f78ffe8e584529324cdfa Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 11:25:02 +0800 Subject: [PATCH 201/400] feat: add title view for favorite scene --- Localization/app.json | 27 +++++++++------- Mastodon.xcodeproj/project.pbxproj | 12 +++---- Mastodon/Generated/Strings.swift | 4 +++ .../Resources/en.lproj/Localizable.strings | 1 + .../HashtagTimelineViewController.swift | 11 +++---- .../Favorite/FavoriteViewController.swift | 3 ++ ...bleTitleLabelNavigationBarTitleView.swift} | 32 +++++++++++++++---- 7 files changed, 58 insertions(+), 32 deletions(-) rename Mastodon/Scene/{HashtagTimeline/View/HashtagTimelineTitleView.swift => Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift} (72%) diff --git a/Localization/app.json b/Localization/app.json index d571d96fb..ea160552b 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -217,7 +217,7 @@ "new_posts": "See new posts", "published": "Published!", "Publishing": "Publishing post..." - }, + } }, "public_timeline": { "title": "Public" @@ -288,21 +288,24 @@ "cancel": "Cancel" }, "recommend": { - "buttonText": "See All", - "hash_tag": { - "title": "Trending in your timeline", - "description": "Hashtags that are getting quite a bit of attention among people you follow", - "people_talking": "%s people are talking" - }, - "accounts": { - "title": "Accounts you might like", - "description": "Except for Sam, you will not like his account.", - "follow": "Follow" - } + "buttonText": "See All", + "hash_tag": { + "title": "Trending in your timeline", + "description": "Hashtags that are getting quite a bit of attention among people you follow", + "people_talking": "%s people are talking" + }, + "accounts": { + "title": "Accounts you might like", + "description": "Except for Sam, you will not like his account.", + "follow": "Follow" + } } }, "hashtag": { "prompt": "%s people talking" + }, + "favorite": { + "title": "Your Favorites" } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2f3edabf7..c2551550a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */; }; + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */; }; 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */; }; 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */; }; 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; @@ -377,7 +377,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineTitleView.swift; sourceTree = ""; }; + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTitleLabelNavigationBarTitleView.swift; sourceTree = ""; }; 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewModel.swift; sourceTree = ""; }; 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; @@ -760,7 +760,6 @@ 0F1E2D102615C39800C38565 /* View */ = { isa = PBXGroup; children = ( - 0F1E2D0A2615C39400C38565 /* HashtagTimelineTitleView.swift */, ); path = View; sourceTree = ""; @@ -770,10 +769,10 @@ children = ( 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, - 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */, 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */, ); @@ -853,6 +852,7 @@ 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */, DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, + 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, ); path = Content; sourceTree = ""; @@ -1510,13 +1510,13 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 0F2021F5261325ED000C64BF /* HashtagTimeline */, 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, @@ -2163,7 +2163,7 @@ DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, - 0F1E2D0B2615C39400C38565 /* HashtagTimelineTitleView.swift in Sources */, + 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 83fb0adea..8ff45af58 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -299,6 +299,10 @@ internal enum L10n { internal static let title = L10n.tr("Localizable", "Scene.ConfirmEmail.OpenEmailApp.Title") } } + internal enum Favorite { + /// Your Favorites + internal static let title = L10n.tr("Localizable", "Scene.Favorite.Title") + } internal enum Hashtag { /// %@ people talking internal static func prompt(_ p1: Any) -> String { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 90d0b11a7..175d63a11 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -102,6 +102,7 @@ uploaded to Mastodon."; "Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, tap the link to confirm your account."; "Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Favorite.Title" = "Your Favorites"; "Scene.Hashtag.Prompt" = "%@ people talking"; "Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; "Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index cefd7b238..6dab0f180 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -41,7 +41,7 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { let refreshControl = UIRefreshControl() - let titleView = HashtagTimelineNavigationBarTitleView() + let titleView = DoubleTitleLabelNavigationBarTitleView() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) @@ -54,7 +54,7 @@ extension HashtagTimelineViewController { super.viewDidLoad() title = "#\(viewModel.hashtag)" - titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: nil) + titleView.update(title: viewModel.hashtag, subtitle: nil) navigationItem.titleView = titleView view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color @@ -107,9 +107,6 @@ extension HashtagTimelineViewController { self?.updatePromptTitle() } .store(in: &disposeBag) - - - } override func viewWillAppear(_ animated: Bool) { @@ -142,7 +139,7 @@ extension HashtagTimelineViewController { private func updatePromptTitle() { var subtitle: String? defer { - titleView.updateTitle(hashtag: viewModel.hashtag, peopleNumber: subtitle) + titleView.update(title: "#" + viewModel.hashtag, subtitle: subtitle) } guard let histories = viewModel.hashtagEntity.value?.history else { return @@ -158,7 +155,7 @@ extension HashtagTimelineViewController { .prefix(2) .compactMap({ Int($0.accounts) }) .reduce(0, +) - subtitle = "\(peopleTalkingNumber)" + subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)") } } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 1f8a1d332..2a873586c 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -21,6 +21,8 @@ final class FavoriteViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: FavoriteViewModel! + + let titleView = DoubleTitleLabelNavigationBarTitleView() lazy var tableView: UITableView = { let tableView = UITableView() @@ -44,6 +46,7 @@ extension FavoriteViewController { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + navigationItem.titleView = titleView tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) diff --git a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift similarity index 72% rename from Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift rename to Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift index 78d5a971c..b136859a8 100644 --- a/Mastodon/Scene/HashtagTimeline/View/HashtagTimelineTitleView.swift +++ b/Mastodon/Scene/Share/View/Content/DoubleTitleLabelNavigationBarTitleView.swift @@ -1,5 +1,5 @@ // -// HashtagTimelineTitleView.swift +// DoubleTitleLabelNavigationBarTitleView.swift // Mastodon // // Created by BradGao on 2021/4/1. @@ -7,7 +7,7 @@ import UIKit -final class HashtagTimelineNavigationBarTitleView: UIView { +final class DoubleTitleLabelNavigationBarTitleView: UIView { let containerView = UIStackView() @@ -40,7 +40,7 @@ final class HashtagTimelineNavigationBarTitleView: UIView { } -extension HashtagTimelineNavigationBarTitleView { +extension DoubleTitleLabelNavigationBarTitleView { private func _init() { containerView.axis = .vertical containerView.alignment = .center @@ -58,10 +58,10 @@ extension HashtagTimelineNavigationBarTitleView { containerView.addArrangedSubview(subtitleLabel) } - func updateTitle(hashtag: String, peopleNumber: String?) { - titleLabel.text = "#\(hashtag)" - if let peopleNumebr = peopleNumber { - subtitleLabel.text = L10n.Scene.Hashtag.prompt(peopleNumebr) + func update(title: String, subtitle: String?) { + titleLabel.text = title + if let subtitle = subtitle { + subtitleLabel.text = subtitle subtitleLabel.isHidden = false } else { subtitleLabel.text = nil @@ -69,3 +69,21 @@ extension HashtagTimelineNavigationBarTitleView { } } } + +#if canImport(SwiftUI) && DEBUG + +import SwiftUI + +struct DoubleTitleLabelNavigationBarTitleView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + DoubleTitleLabelNavigationBarTitleView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + From 1deb75d041cad7ccf5f31e97c38ada6fa0137e86 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 11:26:15 +0800 Subject: [PATCH 202/400] chore: update cell height estimate --- Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift index ce5e453cd..7c851cadc 100644 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -22,7 +22,7 @@ extension TableViewCellHeightCacheableContainer { } func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let item = item(for: nil, indexPath: indexPath) else { return 200 } + guard let item = item(for: nil, indexPath: indexPath) else { return UITableView.automaticDimension } guard let frame = cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { if case .bottomLoader = item { return TimelineLoaderTableViewCell.cellHeight From 74ee93e9831d15067e810673793217bdf9ee7c7b Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 11:26:46 +0800 Subject: [PATCH 203/400] chore: set title label for favorite scene --- Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 2a873586c..0b4a01a40 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -47,6 +47,7 @@ extension FavoriteViewController { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color navigationItem.titleView = titleView + titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) From 0c8134463ffeb9e6c939b34103606d0f2d3c4611 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 14:11:35 +0800 Subject: [PATCH 204/400] fix: audio pause condition logic issue --- .../StatusProvider/StatusProvider+UITableViewDelegate.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index baaa708a1..32915baf9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -95,7 +95,8 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { videoPlayerViewModel.didEndDisplaying() } } - if let currentAudioAttachment = self.context.audioPlaybackService.attachment, let _ = status?.mediaAttachments?.contains(currentAudioAttachment) { + if let currentAudioAttachment = self.context.audioPlaybackService.attachment, + status?.mediaAttachments?.contains(currentAudioAttachment) == true { self.context.audioPlaybackService.pause() } } From ba48adb470c4668ec53db96605e71636be5511f5 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 16:27:26 +0800 Subject: [PATCH 205/400] chore: make favorite and hashtag scene use next page token from response header --- ...tagTimelineViewModel+LoadOldestState.swift | 17 ++++++++-- .../Favorite/FavoriteViewModel+State.swift | 19 +++++++++-- .../Response/Mastodon+Response+Content.swift | 34 +++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index d0607550e..e5c78f3d5 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -35,6 +35,8 @@ extension HashtagTimelineViewModel.LoadOldestState { } class Loading: HashtagTimelineViewModel.LoadOldestState { + var maxID: String? + override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self } @@ -54,7 +56,7 @@ extension HashtagTimelineViewModel.LoadOldestState { } // TODO: only set large count when using Wi-Fi - let maxID = last.id + let maxID = self.maxID ?? last.id viewModel.context.apiService.hashtagTimeline( domain: activeMastodonAuthenticationBox.domain, maxID: maxID, @@ -71,10 +73,19 @@ extension HashtagTimelineViewModel.LoadOldestState { // handle isFetchingLatestTimeline in fetch controller delegate break } - } receiveValue: { response in + } receiveValue: { [weak self] response in + guard let self = self else { return } + let statuses = response.value // enter no more state when no new statuses - if statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() + self.maxID = response.link?.maxID + + if !hasNextPage || statuses.isEmpty || (statuses.count == 1 && statuses[0].id == maxID) { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift index 134adb0a3..c4420e88b 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+State.swift @@ -92,6 +92,9 @@ extension FavoriteViewModel.State { } class Loading: FavoriteViewModel.State { + + var maxID: String? + override func isValidNextState(_ stateClass: AnyClass) -> Bool { switch stateClass { case is Fail.Type: @@ -113,8 +116,11 @@ extension FavoriteViewModel.State { stateMachine.enter(Fail.self) return } - - let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last + if previousState is Reloading { + maxID = nil + } + // prefer use `maxID` token in response header + // let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last viewModel.context.apiService.favoritedStatuses( maxID: maxID, @@ -139,8 +145,15 @@ extension FavoriteViewModel.State { statusIDs.append(status.id) hasNewStatusesAppend = true } + + self.maxID = response.link?.maxID + + let hasNextPage: Bool = { + guard let link = response.link else { return true } // assert has more when link invalid + return link.maxID != nil + }() - if hasNewStatusesAppend { + if hasNewStatusesAppend && hasNextPage { stateMachine.enter(Idle.self) } else { stateMachine.enter(NoMore.self) diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index a74d0fcaa..9c39615f9 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -18,6 +18,7 @@ extension Mastodon.Response { // application fields public let rateLimit: RateLimit? + public let link: Link? public let responseTime: Int? public var networkDate: Date { @@ -33,6 +34,11 @@ extension Mastodon.Response { }() self.rateLimit = RateLimit(response: response) + self.link = { + guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "link") else { return nil } + return Link(link: string) + }() + self.responseTime = { guard let string = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-response-time") else { return nil } return Int(string) @@ -43,6 +49,7 @@ extension Mastodon.Response { self.value = value self.date = old.date self.rateLimit = old.rateLimit + self.link = old.link self.responseTime = old.responseTime } @@ -90,3 +97,30 @@ extension Mastodon.Response { } } + +extension Mastodon.Response { + public struct Link { + public let maxID: Mastodon.Entity.Status.ID? + public let minID: Mastodon.Entity.Status.ID? + + init(link: String) { + self.maxID = { + guard let regex = try? NSRegularExpression(pattern: "max_id=([[:digit:]]+)", options: []) else { return nil } + let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex.. Date: Thu, 8 Apr 2021 16:53:32 +0800 Subject: [PATCH 206/400] feat: handle suspended account in profile scene --- .../CoreData.xcdatamodel/contents | 3 +- CoreDataStack/Entity/MastodonUser.swift | 10 ++++ Localization/app.json | 4 +- Mastodon.xcodeproj/project.pbxproj | 8 --- Mastodon/Diffiable/Item/Item.swift | 14 ++++- .../CoreDataStack/MastodonUser.swift | 1 + Mastodon/Generated/Strings.swift | 8 ++- .../Resources/en.lproj/Localizable.strings | 4 +- ...meTimelineViewController+DebugAction.swift | 19 +++++++ .../Header/ProfileHeaderViewController.swift | 24 +++++++- .../ProfileRelationshipActionButton.swift | 2 +- .../Scene/Profile/ProfileViewController.swift | 45 +++++++++++---- Mastodon/Scene/Profile/ProfileViewModel.swift | 25 +++++++- .../Timeline/UserTimelineViewModel.swift | 15 ++++- .../View/Content/TimelineHeaderView.swift | 12 +++- .../APIService/APIService+Follow.swift | 57 ++++++++++++++----- .../APIService+CoreData+MastodonUser.swift | 3 + 17 files changed, 205 insertions(+), 49 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index e2f059d50..9e5b7cf39 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -82,6 +82,7 @@ + @@ -199,7 +200,7 @@ - + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 878eb9ad4..714b6d0f6 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -31,6 +31,7 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var locked: Bool @NSManaged public private(set) var bot: Bool + @NSManaged public private(set) var suspended: Bool @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -93,6 +94,7 @@ extension MastodonUser { user.locked = property.locked user.bot = property.bot ?? false + user.suspended = property.suspended ?? false // Mastodon do not provide relationship on the `Account` // Update relationship via attribute updating interface @@ -174,6 +176,11 @@ extension MastodonUser { self.bot = bot } } + public func update(suspended: Bool) { + if self.suspended != suspended { + self.suspended = suspended + } + } public func update(isFollowing: Bool, by mastodonUser: MastodonUser) { if isFollowing { @@ -268,6 +275,7 @@ extension MastodonUser { public let followersCount: Int public let locked: Bool public let bot: Bool? + public let suspended: Bool? public let createdAt: Date public let networkDate: Date @@ -289,6 +297,7 @@ extension MastodonUser { followersCount: Int, locked: Bool, bot: Bool?, + suspended: Bool?, createdAt: Date, networkDate: Date ) { @@ -309,6 +318,7 @@ extension MastodonUser { self.followersCount = followersCount self.locked = locked self.bot = bot + self.suspended = suspended self.createdAt = createdAt self.networkDate = networkDate } diff --git a/Localization/app.json b/Localization/app.json index ea160552b..1e0f6eaa5 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -69,6 +69,7 @@ "firendship": { "follow": "Follow", "following": "Following", + "request": "Request", "pending": "Pending", "block": "Block", "block_user": "Block %s", @@ -91,7 +92,8 @@ "no_status_found": "No Status Found", "blocking_warning": "You can’t view Artbot’s profile\n until you unblock them.\nYour account looks like this to them.", "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", - "suspended_warning": "This account is suspended." + "suspended_warning": "This account has been suspended.", + "user_suspended_warning": "%s's account has been suspended." } } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c2551550a..353578ba3 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -757,17 +757,9 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0F1E2D102615C39800C38565 /* View */ = { - isa = PBXGroup; - children = ( - ); - path = View; - sourceTree = ""; - }; 0F2021F5261325ED000C64BF /* HashtagTimeline */ = { isa = PBXGroup; children = ( - 0F1E2D102615C39800C38565 /* View */, 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 0a27f1871..9f82f6ca5 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -63,11 +63,21 @@ extension Item { let id = UUID() let reason: Reason - enum Reason { + enum Reason: Equatable { case noStatusFound case blocking case blocked - case suspended + case suspended(name: String?) + + static func == (lhs: Item.EmptyStateHeaderAttribute.Reason, rhs: Item.EmptyStateHeaderAttribute.Reason) -> Bool { + switch (lhs, rhs) { + case (.noStatusFound, noStatusFound): return true + case (.blocking, blocking): return true + case (.blocked, blocked): return true + case (.suspended(let nameLeft), .suspended(let nameRight)): return nameLeft == nameRight + default: return false + } + } } init(reason: Reason) { diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index e140ab95a..4e2138306 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -28,6 +28,7 @@ extension MastodonUser.Property { followersCount: entity.followersCount, locked: entity.locked, bot: entity.bot, + suspended: entity.suspended, createdAt: entity.createdAt, networkDate: networkDate ) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8ff45af58..d20ef1b25 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -112,6 +112,8 @@ internal enum L10n { } /// Pending internal static let pending = L10n.tr("Localizable", "Common.Controls.Firendship.Pending") + /// Request + internal static let request = L10n.tr("Localizable", "Common.Controls.Firendship.Request") /// Unblock internal static let unblock = L10n.tr("Localizable", "Common.Controls.Firendship.Unblock") /// Unblock %@ @@ -179,8 +181,12 @@ internal enum L10n { internal static let blockingWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockingWarning") /// No Status Found internal static let noStatusFound = L10n.tr("Localizable", "Common.Controls.Timeline.Header.NoStatusFound") - /// This account is suspended. + /// This account has been suspended. internal static let suspendedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.SuspendedWarning") + /// %@'s account has been suspended. + internal static func userSuspendedWarning(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Header.UserSuspendedWarning", String(describing: p1)) + } } internal enum Loader { /// Loading missing posts... diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 175d63a11..853c75778 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -38,6 +38,7 @@ Please check your internet connection."; "Common.Controls.Firendship.MuteUser" = "Mute %@"; "Common.Controls.Firendship.Muted" = "Muted"; "Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Request" = "Request"; "Common.Controls.Firendship.Unblock" = "Unblock"; "Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.Unmute" = "Unmute"; @@ -60,7 +61,8 @@ Please check your internet connection."; until you unblock them. Your account looks like this to them."; "Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; -"Common.Controls.Timeline.Header.SuspendedWarning" = "This account is suspended."; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; "Common.Countable.Photo.Multiple" = "photos"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 785d97264..70afdecfa 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -25,6 +25,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showPublicTimelineAction(action) }, + UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -277,5 +281,20 @@ extension HomeTimelineViewController { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } + @objc private func showProfileAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") + self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 855581902..4412950ac 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import Combine protocol ProfileHeaderViewControllerDelegate: class { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -21,6 +22,8 @@ final class ProfileHeaderViewController: UIViewController { weak var delegate: ProfileHeaderViewControllerDelegate? + var disposeBag = Set() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) @@ -33,6 +36,8 @@ final class ProfileHeaderViewController: UIViewController { // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero + + let needsSetupBottomShadow = CurrentValueSubject(true) deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -67,6 +72,14 @@ extension ProfileHeaderViewController { ]) pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + + needsSetupBottomShadow + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupBottomShadow in + guard let self = self else { return } + self.setupBottomShadow() + } + .store(in: &disposeBag) } override func viewDidAppear(_ animated: Bool) { @@ -85,7 +98,7 @@ extension ProfileHeaderViewController { super.viewDidLayoutSubviews() delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) - view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + setupBottomShadow() } } @@ -105,6 +118,15 @@ extension ProfileHeaderViewController { containerSafeAreaInset = inset } + func setupBottomShadow() { + guard needsSetupBottomShadow.value else { + view.layer.shadowColor = nil + view.layer.shadowRadius = 0 + return + } + view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) + } + private func updateHeaderBottomShadow(progress: CGFloat) { let alpha = min(max(0, 10 * progress - 9), 1) if bottomShadowAlpha != alpha { diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index 0f6a804b5..64dc9f00e 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -34,7 +34,7 @@ extension ProfileRelationshipActionButton { setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .normal) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor), for: .disabled) + setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { isEnabled = false diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index c1f11155c..0eba0085e 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -136,19 +136,24 @@ extension ProfileViewController { navigationItem.titleView = UIView() - Publishers.CombineLatest3( + Publishers.CombineLatest4( + viewModel.suspended.eraseToAnyPublisher(), viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + .sink { [weak self] suspended, isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in guard let self = self else { return } var items: [UIBarButtonItem] = [] defer { self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil } + guard !suspended else { + return + } + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) @@ -345,6 +350,21 @@ extension ProfileViewController { } } .store(in: &disposeBag) + Publishers.CombineLatest3( + viewModel.isBlocking.eraseToAnyPublisher(), + viewModel.isBlockedBy.eraseToAnyPublisher(), + viewModel.suspended.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isBlocking, isBlockedBy, suspended in + guard let self = self else { return } + let isNeedSetHidden = isBlocking || isBlockedBy || suspended + self.profileHeaderViewController.needsSetupBottomShadow.value = !isNeedSetHidden + self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden + self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden + self.viewModel.needsPagePinToTop.value = isNeedSetHidden + } + .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] bio in @@ -411,6 +431,8 @@ extension ProfileViewController { viewModel.userID.assign(to: \.value, on: userTimelineViewModel.userID).store(in: &disposeBag) viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) + viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) + viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) } } @@ -476,10 +498,15 @@ extension ProfileViewController: UIScrollViewDelegate { contentOffsets.removeAll() } else { containerScrollView.contentOffset.y = topMaxContentOffsetY - if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { - let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y - customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + if viewModel.needsPagePinToTop.value { + // do nothing + } else { + if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { + let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y + customScrollViewContainerController.scrollView.contentOffset.y = contentOffsetY + } } + } // elastically banner image @@ -538,16 +565,14 @@ extension ProfileViewController: ProfileHeaderViewDelegate { switch relationshipAction { case .none: break - case .follow, .following: + case .follow, .reqeust, .pending, .following: UserProviderFacade.toggleUserFollowRelationship(provider: self) .sink { _ in - + // TODO: handle error } receiveValue: { _ in - + // do nothing } .store(in: &disposeBag) - case .pending: - break case .muting: guard let mastodonUser = viewModel.mastodonUser.value else { return } let name = mastodonUser.displayNameWithFallback diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 41fcdcf4c..7db38d33c 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -41,7 +41,7 @@ class ProfileViewModel: NSObject { let followersCount: CurrentValueSubject let protected: CurrentValueSubject - // let suspended: CurrentValueSubject + let suspended: CurrentValueSubject let relationshipActionOptionSet = CurrentValueSubject(.none) let isEditing = CurrentValueSubject(false) @@ -55,6 +55,8 @@ class ProfileViewModel: NSObject { let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) let isMeBarButtonItemsHidden = CurrentValueSubject(true) + let needsPagePinToTop = CurrentValueSubject(false) + init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context self.mastodonUser = CurrentValueSubject(mastodonUser) @@ -62,7 +64,6 @@ class ProfileViewModel: NSObject { self.userID = CurrentValueSubject(mastodonUser?.id) self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) -// self.protected = CurrentValueSubject(twitterUser?.protected) self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) self.bioDescription = CurrentValueSubject(mastodonUser?.note) @@ -71,6 +72,7 @@ class ProfileViewModel: NSObject { self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followingCount) }) self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int(truncating: $0.followersCount) }) self.protected = CurrentValueSubject(mastodonUser?.locked) + self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) super.init() relationshipActionOptionSet @@ -226,6 +228,7 @@ extension ProfileViewModel { self.followingCount.value = mastodonUser.flatMap { Int(truncating: $0.followingCount) } self.followersCount.value = mastodonUser.flatMap { Int(truncating: $0.followersCount) } self.protected.value = mastodonUser?.locked + self.suspended.value = mastodonUser?.suspended ?? false } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { @@ -255,6 +258,14 @@ extension ProfileViewModel { // set with follow action default var relationshipActionSet = RelationshipActionOptionSet([.follow]) + if mastodonUser.locked { + relationshipActionSet.insert(.request) + } + + if mastodonUser.suspended { + relationshipActionSet.insert(.suspended) + } + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false if isFollowing { relationshipActionSet.insert(.following) @@ -308,11 +319,13 @@ extension ProfileViewModel { enum RelationshipAction: Int, CaseIterable { case none // set hide from UI case follow + case reqeust case pending case following case muting case blocked case blocking + case suspended case edit case editing @@ -327,11 +340,13 @@ extension ProfileViewModel { static let none = RelationshipAction.none.option static let follow = RelationshipAction.follow.option + static let request = RelationshipAction.reqeust.option static let pending = RelationshipAction.pending.option static let following = RelationshipAction.following.option static let muting = RelationshipAction.muting.option static let blocked = RelationshipAction.blocked.option static let blocking = RelationshipAction.blocking.option + static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -354,11 +369,13 @@ extension ProfileViewModel { switch highPriorityAction { case .none: return " " case .follow: return L10n.Common.Controls.Firendship.follow + case .reqeust: return L10n.Common.Controls.Firendship.request case .pending: return L10n.Common.Controls.Firendship.pending case .following: return L10n.Common.Controls.Firendship.following case .muting: return L10n.Common.Controls.Firendship.muted case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocking: return L10n.Common.Controls.Firendship.blocked + case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done } @@ -372,11 +389,13 @@ extension ProfileViewModel { switch highPriorityAction { case .none: return Asset.Colors.Button.normal.color case .follow: return Asset.Colors.Button.normal.color + case .reqeust: return Asset.Colors.Button.normal.color case .pending: return Asset.Colors.Button.normal.color case .following: return Asset.Colors.Button.normal.color case .muting: return Asset.Colors.Background.alertYellow.color - case .blocked: return Asset.Colors.Button.disabled.color + case .blocked: return Asset.Colors.Button.normal.color case .blocking: return Asset.Colors.Background.danger.color + case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index ac66f037a..03e5e627d 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -27,6 +27,8 @@ final class UserTimelineViewModel { let isBlocking = CurrentValueSubject(false) let isBlockedBy = CurrentValueSubject(false) + let isSuspended = CurrentValueSubject(false) + let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label // output var diffableDataSource: UITableViewDiffableDataSource? @@ -59,14 +61,15 @@ final class UserTimelineViewModel { .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - Publishers.CombineLatest3( + Publishers.CombineLatest4( statusFetchedResultsController.objectIDs.eraseToAnyPublisher(), isBlocking.eraseToAnyPublisher(), - isBlockedBy.eraseToAnyPublisher() + isBlockedBy.eraseToAnyPublisher(), + isSuspended.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { [weak self] objectIDs, isBlocking, isBlockedBy in + .sink { [weak self] objectIDs, isBlocking, isBlockedBy, isSuspended in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } @@ -89,6 +92,12 @@ final class UserTimelineViewModel { return } + let name = self.userDisplayName.value + guard !isSuspended else { + snapshot.appendItems([Item.emptyStateHeader(attribute: Item.EmptyStateHeaderAttribute(reason: .suspended(name: name)))], toSection: .main) + return + } + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] let oldSnapshot = diffableDataSource.snapshot() for item in oldSnapshot.itemIdentifiers { diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index e253b3ca7..b5e4c5bde 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -84,8 +84,10 @@ extension TimelineHeaderView { extension Item.EmptyStateHeaderAttribute.Reason { var iconImage: UIImage? { switch self { - case .noStatusFound, .blocking, .blocked, .suspended: + case .noStatusFound, .blocking, .blocked: return UIImage(systemName: "nosign", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! + case .suspended: + return UIImage(systemName: "person.crop.circle.badge.xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 64, weight: .bold))! } } @@ -97,8 +99,12 @@ extension Item.EmptyStateHeaderAttribute.Reason { return L10n.Common.Controls.Timeline.Header.blockingWarning case .blocked: return L10n.Common.Controls.Timeline.Header.blockedWarning - case .suspended: - return L10n.Common.Controls.Timeline.Header.suspendedWarning + case .suspended(let name): + if let name = name { + return L10n.Common.Controls.Timeline.Header.userSuspendedWarning(name) + } else { + return L10n.Common.Controls.Timeline.Header.suspendedWarning + } } } } diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index f2c57db57..cf19878d6 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -158,6 +158,7 @@ extension APIService { ) -> AnyPublisher, Error> { let domain = mastodonAuthenticationBox.domain let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID return Mastodon.API.Account.follow( session: session, @@ -166,22 +167,50 @@ extension APIService { followQueryType: followQueryType, authorization: authorization ) - .handleEvents(receiveCompletion: { [weak self] completion in - guard let _ = self else { return } - switch completion { - case .failure(let error): - // TODO: handle error - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - break - case .finished: - switch followQueryType { - case .follow: - break - case .unfollow: - break +// .handleEvents(receiveCompletion: { [weak self] completion in +// guard let _ = self else { return } +// switch completion { +// case .failure(let error): +// // TODO: handle error +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update follow fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// break +// case .finished: +// switch followQueryType { +// case .follow: +// break +// case .unfollow: +// break +// } +// } +// }) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) } } - }) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } .eraseToAnyPublisher() } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift index fdac2a2a6..4a1237051 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+MastodonUser.swift @@ -95,6 +95,9 @@ extension APIService.CoreData { user.update(statusesCount: property.statusesCount) user.update(followingCount: property.followingCount) user.update(followersCount: property.followersCount) + user.update(locked: property.locked) + property.bot.flatMap { user.update(bot: $0) } + property.suspended.flatMap { user.update(suspended: $0) } user.didUpdate(at: networkDate) } From 672db8f3c207c59fd0e75f156e253364dea851a8 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 17:05:10 +0800 Subject: [PATCH 207/400] chore: use fake aspect protocol to group extension default implementations --- .../StatusTableViewControllerAspect.swift | 45 ++++++++++++++---- ...ableViewCellHeightCacheableContainer.swift | 2 +- .../HashtagTimelineViewController.swift | 46 ++++++++++--------- .../Favorite/FavoriteViewController.swift | 17 +++++-- .../ProfileRelationshipActionButton.swift | 2 +- .../Timeline/UserTimelineViewController.swift | 16 +++++-- 6 files changed, 89 insertions(+), 39 deletions(-) diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index dfc1a89eb..ecd8291ff 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -8,6 +8,15 @@ import UIKit import AVKit +// Check List Last Updated +// - FavoriteViewController: 2021/4/8 +// - HashtagTimelineViewController: 2021/4/8 +// - UserTimelineViewController: 2021/4/8 +// * StatusTableViewControllerAspect: 2021/4/7 + +// (Fake) Aspect protocol to group common protocol extension implementations +// Needs update related view controller when aspect interface changes + /// Status related operations aspect /// Please check the aspect methods (Option+Click) and add hook to implement features /// - UI @@ -17,9 +26,9 @@ protocol StatusTableViewControllerAspect: UIViewController { var tableView: UITableView { get } } -// MARK: - UIViewController +// MARK: - UIViewController [A] -// aspectViewWillAppear(_:) +// [A1] aspectViewWillAppear(_:) extension StatusTableViewControllerAspect { /// [UI] hook to deselect row in the transitioning for the table view func aspectViewWillAppear(_ animated: Bool) { @@ -31,12 +40,13 @@ extension StatusTableViewControllerAspect where Self: NeedsDependency { /// [Media] hook to notify video service func aspectViewDidDisappear(_ animated: Bool) { context.videoPlaybackService.viewDidDisappear(from: self) + context.audioPlaybackService.viewDidDisappear(from: self) } } -// MARK: - UITableViewDelegate +// MARK: - UITableViewDelegate [B] -// aspectTableView(_:estimatedHeightForRowAt:) +// [B1] aspectTableView(_:estimatedHeightForRowAt:) extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableViewContainer { /// [Data Source] hook to notify table view bottom loader func aspectScrollViewDidScroll(_ scrollView: UIScrollView) { @@ -44,7 +54,7 @@ extension StatusTableViewControllerAspect where Self: LoadMoreConfigurableTableV } } -// aspectTableView(_:estimatedHeightForRowAt:) +// [B2] aspectTableView(_:estimatedHeightForRowAt:) extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheableContainer { /// [UI] hook to estimate table view cell height from cache func aspectTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { @@ -52,7 +62,14 @@ extension StatusTableViewControllerAspect where Self: TableViewCellHeightCacheab } } -// StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) +// [B3] aspectTableView(_:willDisplay:forRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + func aspectTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } +} + +// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { /// [Media] hook to notify video service func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -76,9 +93,19 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } -// MARK: - AVPlayerViewControllerDelegate & NeedsDependency +// MARK: - UITableViewDataSourcePrefetching [C] -// aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) +// [C1] aspectTableView(:prefetchRowsAt) +extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { + /// [Data Source] hook to prefetch reply to info for status + func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D] + +// [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { /// [Media] hook to mark transitioning to video service func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { @@ -86,7 +113,7 @@ extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDele } } -// aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) +// [D2] aspectPlayerViewController(_:willEndFullScreenPresentationWithAnimationCoordinator:) extension StatusTableViewControllerAspect where Self: AVPlayerViewControllerDelegate & NeedsDependency { /// [Media] hook to mark transitioning to video service func aspectPlayerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { diff --git a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift index 7c851cadc..0907db56f 100644 --- a/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift +++ b/Mastodon/Protocol/TableViewCellHeightCacheableContainer.swift @@ -27,7 +27,7 @@ extension TableViewCellHeightCacheableContainer { if case .bottomLoader = item { return TimelineLoaderTableViewCell.cellHeight } else { - return 200 + return UITableView.automaticDimension } } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 6dab0f180..c9bf87410 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -111,6 +111,9 @@ extension HashtagTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + + aspectViewWillAppear(animated) + viewModel.fetchTag() guard viewModel.loadLatestStateMachine.currentState is HashtagTimelineViewModel.LoadLatestState.Initial else { return } @@ -120,8 +123,8 @@ extension HashtagTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) + + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -158,6 +161,7 @@ extension HashtagTimelineViewController { subtitle = L10n.Scene.Hashtag.prompt("\(peopleTalkingNumber)") } } + } extension HashtagTimelineViewController { @@ -176,11 +180,20 @@ extension HashtagTimelineViewController { } } +// MARK: - StatusTableViewControllerAspect +extension HashtagTimelineViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension HashtagTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { + return viewModel.cellFrameCache + } +} + // MARK: - UIScrollViewDelegate extension HashtagTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) -// self.viewModel.homeTimelineNavigationBarState.handleScrollViewDidScroll(scrollView) + aspectScrollViewDidScroll(scrollView) } } @@ -194,25 +207,16 @@ extension HashtagTimelineViewController: LoadMoreConfigurableTableViewContainer // MARK: - UITableViewDelegate extension HashtagTimelineViewController: UITableViewDelegate { - // TODO: - // func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - // - // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - // return 200 - // } - // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - // - // return ceil(frame.height) - // } + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } @@ -227,7 +231,7 @@ extension HashtagTimelineViewController: ContentOffsetAdjustableTimelineViewCont // MARK: - UITableViewDataSourcePrefetching extension HashtagTimelineViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) + aspectTableView(tableView, prefetchRowsAt: indexPaths) } } @@ -298,11 +302,11 @@ extension HashtagTimelineViewController: TimelineMiddleLoaderTableViewCellDelega extension HashtagTimelineViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 0b4a01a40..a175ae348 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -59,6 +59,7 @@ extension FavoriteViewController { ]) tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -105,26 +106,36 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, estimatedHeightForRowAt: indexPath) } + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } +// MARK: - UITableViewDataSourcePrefetching +extension FavoriteViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - AVPlayerViewControllerDelegate extension FavoriteViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) } func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { - handlePlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) } } - // MARK: - TimelinePostTableViewCellDelegate extension FavoriteViewController: StatusTableViewCellDelegate { weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index 64dc9f00e..70e6e1647 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -36,7 +36,7 @@ extension ProfileRelationshipActionButton { setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked { + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { isEnabled = false } else { isEnabled = true diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 4219f5117..e8e71ccf4 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -57,6 +57,7 @@ extension UserTimelineViewController { ]) tableView.delegate = self + tableView.prefetchDataSource = self viewModel.setupDiffableDataSource( for: tableView, dependency: self, @@ -115,12 +116,23 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, estimatedHeightForRowAt: indexPath) } + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } } +// MARK: - UITableViewDataSourcePrefetching +extension UserTimelineViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + // MARK: - AVPlayerViewControllerDelegate extension UserTimelineViewController: AVPlayerViewControllerDelegate { @@ -140,10 +152,6 @@ extension UserTimelineViewController: StatusTableViewCellDelegate { func parent() -> UIViewController { return self } } -//// MARK: - TimelineHeaderTableViewCellDelegate -//extension UserTimelineViewController: TimelineHeaderTableViewCellDelegate { } - - // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { var scrollView: UIScrollView { return tableView } From 3b03ed63ceea15d56797d6534c2715624470ddc0 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 18:52:35 +0800 Subject: [PATCH 208/400] feat: add share action for profile scene --- Localization/app.json | 3 + Mastodon.xcodeproj/project.pbxproj | 14 ++++- Mastodon/Activity/SafariActivity.swift | 62 +++++++++++++++++++ Mastodon/Coordinator/SceneCoordinator.swift | 25 +++++--- .../CoreDataStack/MastodonUser.swift | 18 ++++++ Mastodon/Generated/Strings.swift | 10 +++ .../UserProvider/UserProviderFacade.swift | 30 ++++++++- .../Resources/en.lproj/Localizable.strings | 3 + .../Scene/Profile/ProfileViewController.swift | 14 ++++- 9 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Activity/SafariActivity.swift diff --git a/Localization/app.json b/Localization/app.json index 1e0f6eaa5..a43e56c10 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -44,6 +44,8 @@ "sign_up": "Sign Up", "see_more": "See More", "preview": "Preview", + "share": "Share", + "share_user": "Share %s", "open_in_safari": "Open in Safari" }, "status": { @@ -73,6 +75,7 @@ "pending": "Pending", "block": "Block", "block_user": "Block %s", + "block_domain": "Block %s", "unblock": "Unblock", "unblock_user": "Unblock %s", "blocked": "Blocked", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 353578ba3..36d2e0e77 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -219,6 +219,7 @@ DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */; }; DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; @@ -595,6 +596,7 @@ DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; + DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; @@ -1142,8 +1144,8 @@ DB084B5125CBC56300F898ED /* CoreDataStack */ = { isa = PBXGroup; children = ( - DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB084B5625CBC56C00F898ED /* Status.swift */, + DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, ); path = CoreDataStack; @@ -1233,6 +1235,7 @@ DB9E0D6925EDFFE500CFDD76 /* Helper */, DB8AF56225C138BC002E6C99 /* Extension */, 2D5A3D0125CF8640002347D6 /* Vender */, + DB73B495261F030D002E9E9F /* Activity */, DB5086CB25CC0DB400C2C187 /* Preference */, 2D69CFF225CA9E2200C3A1B2 /* Protocol */, DB98338425C945ED00AD9700 /* Generated */, @@ -1372,6 +1375,14 @@ path = ServerRules; sourceTree = ""; }; + DB73B495261F030D002E9E9F /* Activity */ = { + isa = PBXGroup; + children = ( + DB73B48F261F030A002E9E9F /* SafariActivity.swift */, + ); + path = Activity; + sourceTree = ""; + }; DB789A1025F9F29B0071ACA0 /* Compose */ = { isa = PBXGroup; children = ( @@ -2219,6 +2230,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, diff --git a/Mastodon/Activity/SafariActivity.swift b/Mastodon/Activity/SafariActivity.swift new file mode 100644 index 000000000..e10a0b082 --- /dev/null +++ b/Mastodon/Activity/SafariActivity.swift @@ -0,0 +1,62 @@ +// +// SafariActivity.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-8. +// + +import UIKit +import SafariServices + +final class SafariActivity: UIActivity { + + weak var sceneCoordinator: SceneCoordinator? + var url: NSURL? + + init(sceneCoordinator: SceneCoordinator) { + self.sceneCoordinator = sceneCoordinator + } + + override var activityType: UIActivity.ActivityType? { + return UIActivity.ActivityType("org.joinmastodon.Mastodon.safari-activity") + } + + override var activityTitle: String? { + return L10n.Common.Controls.Actions.openInSafari + } + + override var activityImage: UIImage? { + return UIImage(systemName: "safari") + } + + override func canPerform(withActivityItems activityItems: [Any]) -> Bool { + for item in activityItems { + guard let _ = item as? NSURL, sceneCoordinator != nil else { continue } + return true + } + + return false + } + + override func prepare(withActivityItems activityItems: [Any]) { + for item in activityItems { + guard let url = item as? NSURL else { continue } + self.url = url + } + } + + override var activityViewController: UIViewController? { + return nil + } + + override func perform() { + guard let url = url else { + activityDidFinish(false) + return + } + + sceneCoordinator?.present(scene: .safari(url: url as URL), from: nil, transition: .safariPresent(animated: true, completion: nil)) + activityDidFinish(true) + } + +} diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 6ad34ec30..36d42745e 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -33,8 +33,8 @@ extension SceneCoordinator { case custom(transitioningDelegate: UIViewControllerTransitioningDelegate) case customPush case safariPresent(animated: Bool, completion: (() -> Void)? = nil) - case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) case alertController(animated: Bool, completion: (() -> Void)? = nil) + case activityViewControllerPresent(animated: Bool, completion: (() -> Void)? = nil) } enum Scene { @@ -59,8 +59,9 @@ extension SceneCoordinator { case favorite(viewModel: FavoriteViewModel) // misc - case alertController(alertController: UIAlertController) case safari(url: URL) + case alertController(alertController: UIAlertController) + case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) #if DEBUG case publicTimeline @@ -170,11 +171,11 @@ extension SceneCoordinator { viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) - case .activityViewControllerPresent(let animated, let completion): + case .alertController(let animated, let completion): viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) - case .alertController(let animated, let completion): + case .activityViewControllerPresent(let animated, let completion): viewController.modalPresentationCapturesStatusBarAppearance = true presentingViewController.present(viewController, animated: animated, completion: completion) } @@ -237,6 +238,12 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .safari(let url): + guard let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return nil + } + viewController = SFSafariViewController(url: url) case .alertController(let alertController): if let popoverPresentationController = alertController.popoverPresentationController { assert( @@ -246,12 +253,10 @@ private extension SceneCoordinator { ) } viewController = alertController - case .safari(let url): - guard let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" else { - return nil - } - viewController = SFSafariViewController(url: url) + case .activityViewController(let activityViewController, let sourceView, let barButtonItem): + activityViewController.popoverPresentationController?.sourceView = sourceView + activityViewController.popoverPresentationController?.barButtonItem = barButtonItem + viewController = activityViewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index 4e2138306..bb99b15d4 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -63,3 +63,21 @@ extension MastodonUser { } } + +extension MastodonUser { + + var profileURL: URL { + if let urlString = self.url, + let url = URL(string: urlString) { + return url + } else { + return URL(string: "https://\(self.domain)/@\(username)")! + } + } + + var activityItems: [Any] { + var items: [Any] = [] + items.append(profileURL) + return items + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d20ef1b25..7e50253b4 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -78,6 +78,12 @@ internal enum L10n { internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") /// See More internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") + /// Share + internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") + /// Share %@ + internal static func shareUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1)) + } /// Sign In internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") /// Sign Up @@ -90,6 +96,10 @@ internal enum L10n { internal enum Firendship { /// Block internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") + /// Block %@ + internal static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) + } /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") /// Block %@ diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 04297772b..b5f4dd32f 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -139,7 +139,10 @@ extension UserProviderFacade { for mastodonUser: MastodonUser, isMuting: Bool, isBlocking: Bool, - provider: UserProvider + needsShareAction: Bool, + provider: UserProvider, + sourceView: UIView?, + barButtonItem: UIBarButtonItem? ) -> UIMenu { var children: [UIMenuElement] = [] let name = mastodonUser.displayNameWithFallback @@ -198,7 +201,32 @@ extension UserProviderFacade { children.append(blockMenu) } + if needsShareAction { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } + return UIMenu(title: "", options: [], children: children) } + static func createActivityViewControllerForMastodonUser(mastodonUser: MastodonUser, dependency: NeedsDependency) -> UIActivityViewController { + let activityViewController = UIActivityViewController( + activityItems: mastodonUser.activityItems, + applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] + ) + return activityViewController + } + } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 853c75778..64b62233f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -24,11 +24,14 @@ Please check your internet connection."; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; "Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 0eba0085e..f070f72c6 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -325,7 +325,8 @@ extension ProfileViewController { } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) - self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, provider: self) + let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, needsShareAction: needsShareAction, provider: self, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem) } .store(in: &disposeBag) viewModel.isRelationshipActionButtonHidden @@ -446,6 +447,17 @@ extension ProfileViewController { @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let activityViewController = UserProviderFacade.createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: self) + coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: nil, + barButtonItem: sender + ), + from: self, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) } @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { From 0ddf9d8abee6f9f62b07737ca75e8f82de309ad8 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 8 Apr 2021 18:54:35 +0800 Subject: [PATCH 209/400] chore: code clean up --- Mastodon.xcodeproj/project.pbxproj | 4 ---- Mastodon/ViewController.swift | 15 --------------- 2 files changed, 19 deletions(-) delete mode 100644 Mastodon/ViewController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 36d2e0e77..7e2abbc65 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -303,7 +303,6 @@ DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */; }; DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B2F261440A50045B23D /* UITabBarController.swift */; }; DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B35261440BA0045B23D /* UINavigationController.swift */; }; - DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B7A261443AD0045B23D /* ViewController.swift */; }; DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B88261454BA0045B23D /* CGImage.swift */; }; DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */; }; DBCC3B9526157E6E0045B23D /* APIService+Relationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */; }; @@ -681,7 +680,6 @@ DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFetchedResultsController.swift; sourceTree = ""; }; DBCC3B2F261440A50045B23D /* UITabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITabBarController.swift; sourceTree = ""; }; DBCC3B35261440BA0045B23D /* UINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UINavigationController.swift; sourceTree = ""; }; - DBCC3B7A261443AD0045B23D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; DBCC3B88261454BA0045B23D /* CGImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGImage.swift; sourceTree = ""; }; DBCC3B8E26148F7B0045B23D /* CachedProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedProfileViewModel.swift; sourceTree = ""; }; DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Relationship.swift"; sourceTree = ""; }; @@ -1226,7 +1224,6 @@ children = ( DB427DE325BAA00100D1B89D /* Info.plist */, DB89BA1025C10FF5008580ED /* Mastodon.entitlements */, - DBCC3B7A261443AD0045B23D /* ViewController.swift */, 2D76319C25C151DE00929FB9 /* Diffiable */, DB8AF52A25C13561002E6C99 /* State */, 2D61335525C1886800CAE157 /* Service */, @@ -2248,7 +2245,6 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, - DBCC3B7B261443AD0045B23D /* ViewController.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, diff --git a/Mastodon/ViewController.swift b/Mastodon/ViewController.swift deleted file mode 100644 index 9be4589cc..000000000 --- a/Mastodon/ViewController.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-31. -// - -import UIKit - -class ViewController: UIViewController { - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } -} From 2ce5c4db6b38d78b27b41ab55b8885a04d65c27c Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 9 Apr 2021 11:05:10 +0800 Subject: [PATCH 210/400] fix: pick server scene leaking issue --- .../Diffiable/Section/CategoryPickerSection.swift | 3 ++- .../Diffiable/Section/CustomEmojiPickerSection.swift | 3 ++- Mastodon/Diffiable/Section/PickServerSection.swift | 8 +++++++- .../HomeTimelineViewController+DebugAction.swift | 8 ++++++++ .../PickServer/MastodonPickServerViewController.swift | 11 +++++++++++ .../PickServer/MastodonPickServerViewModel.swift | 8 ++++++++ 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 52443a13d..456d193f3 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -16,7 +16,8 @@ extension CategoryPickerSection { for collectionView: UICollectionView, dependency: NeedsDependency ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: PickServerCategoryCollectionViewCell.self), for: indexPath) as! PickServerCategoryCollectionViewCell switch item { case .all: diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift index 2167d6f5c..06b626d0e 100644 --- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -17,7 +17,8 @@ extension CustomEmojiPickerSection { for collectionView: UICollectionView, dependency: NeedsDependency ) -> UICollectionViewDiffableDataSource { - let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak dependency] collectionView, indexPath, item -> UICollectionViewCell? in + guard let _ = dependency else { return nil } switch item { case .emoji(let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: CustomEmojiPickerItemCollectionViewCell.self), for: indexPath) as! CustomEmojiPickerItemCollectionViewCell diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index 083e9b813..f5b1ee500 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -25,7 +25,13 @@ extension PickServerSection { pickServerSearchCellDelegate: PickServerSearchCellDelegate, pickServerCellDelegate: PickServerCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak pickServerCategoriesCellDelegate, weak pickServerSearchCellDelegate, weak pickServerCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak pickServerCategoriesCellDelegate, + weak pickServerSearchCellDelegate, + weak pickServerCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return nil } switch item { case .header: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerTitleCell.self), for: indexPath) as! PickServerTitleCell diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 70afdecfa..0c43af79e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -21,6 +21,10 @@ extension HomeTimelineViewController { children: [ moveMenu, dropMenu, + UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) @@ -277,6 +281,10 @@ extension HomeTimelineViewController { .store(in: &disposeBag) } + @objc private func showWelcomeAction(_ sender: UIAction) { + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + @objc private func showPublicTimelineAction(_ sender: UIAction) { coordinator.present(scene: .publicTimeline, from: self, transition: .show) } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 721aae9bc..241a597c1 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -66,6 +66,17 @@ extension MastodonPickServerViewController { setupOnboardingAppearance() defer { setupNavigationBarBackgroundView() } + + #if DEBUG + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) + let children: [UIMenuElement] = [ + UIAction(title: "Dismiss", image: nil, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + }) + ] + navigationItem.rightBarButtonItem?.menu = UIMenu(title: "Debug Tool", image: nil, identifier: nil, options: [], children: children) + #endif view.addSubview(nextStepButton) NSLayoutConstraint.activate([ diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index 09b6c327c..ed804afd9 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -75,6 +75,14 @@ class MastodonPickServerViewModel: NSObject { configure() } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MastodonPickServerViewModel { + private func configure() { Publishers.CombineLatest( filteredIndexedServers.eraseToAnyPublisher(), From 0418ec147021f2f3d1fa2646814a8323b7bd5ffa Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 9 Apr 2021 13:09:30 +0800 Subject: [PATCH 211/400] chore: recommend account use CoreData dateSource --- .../Section/RecommendAccountSection.swift | 10 ++- ...hRecommendAccountsCollectionViewCell.swift | 3 +- .../SearchViewController+Recommend.swift | 5 +- .../Scene/Search/SearchViewController.swift | 2 +- Mastodon/Scene/Search/SearchViewModel.swift | 70 +++++++++---------- .../APIService/APIService+Recommend.swift | 31 ++++++-- 6 files changed, 73 insertions(+), 48 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index b08c9abab..ac3feb328 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -8,6 +8,8 @@ import Foundation import MastodonSDK import UIKit +import CoreData +import CoreDataStack enum RecommendAccountSection: Equatable, Hashable { case main @@ -15,10 +17,12 @@ enum RecommendAccountSection: Equatable, Hashable { extension RecommendAccountSection { static func collectionViewDiffableDataSource( - for collectionView: UICollectionView - ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, account -> UICollectionViewCell? in + for collectionView: UICollectionView, + managedObjectContext: NSManagedObjectContext + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell + let account = managedObjectContext.object(with: objectID) as! MastodonUser cell.config(with: account) return cell } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 626a2b4b6..4380d98f9 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -8,6 +8,7 @@ import Foundation import MastodonSDK import UIKit +import CoreDataStack class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let avatarImageView: UIImageView = { @@ -122,7 +123,7 @@ extension SearchRecommendAccountsCollectionViewCell { ]) } - func config(with account: Mastodon.Entity.Account) { + func config(with account: MastodonUser) { displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName acctLabel.text = account.acct avatarImageView.af.setImage( diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index 056df2e38..e941fa841 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -59,8 +59,9 @@ extension SearchViewController: UICollectionViewDelegate { switch collectionView { case self.accountsCollectionView: guard let diffableDataSource = viewModel.accountDiffableDataSource else { return } - guard let account = diffableDataSource.itemIdentifier(for: indexPath) else { return } - viewModel.accountCollectionViewItemDidSelected(account: account, from: self) + guard let accountObjectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } + let user = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + viewModel.accountCollectionViewItemDidSelected(mastodonUser: user, from: self) case self.hashtagCollectionView: guard let diffableDataSource = viewModel.hashtagDiffableDataSource else { return } guard let hashtag = diffableDataSource.itemIdentifier(for: indexPath) else { return } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index b98a34b95..f697ef528 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -150,7 +150,7 @@ extension SearchViewController { func setupDataSource() { viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) - viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, managedObjectContext: context.managedObjectContext) viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 46255fde1..c3a5987ba 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -30,10 +30,10 @@ final class SearchViewModel: NSObject { let searchResult = CurrentValueSubject(nil) var recommendHashTags = [Mastodon.Entity.Tag]() - var recommendAccounts = [Mastodon.Entity.Account]() + var recommendAccounts = [NSManagedObjectID]() var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? - var accountDiffableDataSource: UICollectionViewDiffableDataSource? + var accountDiffableDataSource: UICollectionViewDiffableDataSource? var searchResultDiffableDataSource: UITableViewDiffableDataSource? // bottom loader @@ -52,7 +52,7 @@ final class SearchViewModel: NSObject { lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) - init(context: AppContext,coordinator: SceneCoordinator) { + init(context: AppContext, coordinator: SceneCoordinator) { self.coordinator = coordinator self.context = context super.init() @@ -102,7 +102,7 @@ final class SearchViewModel: NSObject { searchText, searchScope ) - .filter { isSearching, text, _ in + .filter { isSearching, _, _ in isSearching } .sink { [weak self] _, text, scope in @@ -151,7 +151,7 @@ final class SearchViewModel: NSObject { guard let self = self else { return } if !self.recommendAccounts.isEmpty { guard let dataSource = self.accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(self.recommendAccounts, toSection: .main) dataSource.apply(snapshot, animatingDifferences: false, completion: nil) @@ -170,7 +170,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.account]) let items = accounts.compactMap { SearchResultItem.account(account: $0) } snapshot.appendItems(items, toSection: .account) - if self.searchScope.value == Mastodon.API.Search.SearchType.accounts && !items.isEmpty { + if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .account) } } @@ -178,7 +178,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.hashtag]) let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) } snapshot.appendItems(items, toSection: .hashtag) - if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags && !items.isEmpty { + if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .hashtag) } } @@ -229,49 +229,45 @@ final class SearchViewModel: NSObject { } } receiveValue: { [weak self] accounts in guard let self = self else { return } - self.recommendAccounts = accounts.value + let ids = accounts.value.compactMap({$0.id}) + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + let mastodonUsers: [MastodonUser]? = { + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + userFetchRequest.returnsObjectsAsFaults = false + do { + return try self.context.managedObjectContext.fetch(userFetchRequest) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let users = mastodonUsers { + self.recommendAccounts = users.map(\.objectID) + } } .store(in: &self.disposeBag) } } - func accountCollectionViewItemDidSelected(account: Mastodon.Entity.Account, from: UIViewController) { - _ = context.managedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - // load request mastodon user - let requestMastodonUser: MastodonUser? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.context.managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api) - let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: mastodonUser) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) - } + func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: from, transition: .show) } } func hashtagCollectionViewItemDidSelected(hashtag: Mastodon.Entity.Tag, from: UIViewController) { - let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: hashtag) - let viewModel = HashtagTimelineViewModel(context: self.context, hashtag: tagInCoreData.name) + let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: context.managedObjectContext, entity: hashtag) + let viewModel = HashtagTimelineViewModel(context: context, hashtag: tagInCoreData.name) DispatchQueue.main.async { self.coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: from, transition: .show) } } - func searchResultItemDidSelected(item: SearchResultItem,from: UIViewController) { - let searchHistories = self.fetchSearchHistory() + func searchResultItemDidSelected(item: SearchResultItem, from: UIViewController) { + let searchHistories = fetchSearchHistory() _ = context.managedObjectContext.performChanges { [weak self] in guard let self = self else { return } switch item { @@ -312,7 +308,7 @@ final class SearchViewModel: NSObject { } case .hashtag(let tag): - let (tagInCoreData,_) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) + let (tagInCoreData, _) = APIService.CoreData.createOrMergeTag(into: self.context.managedObjectContext, entity: tag) if let searchHistories = searchHistories { let history = searchHistories.first { history -> Bool in guard let hashtag = history.hashtag else { return false } diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index bf6db0179..1c58fc575 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -5,12 +5,14 @@ // Created by sxiaojian on 2021/3/31. // +import Combine import Foundation import MastodonSDK -import Combine +import CoreData +import CoreDataStack +import OSLog extension APIService { - func recommendAccount( domain: String, query: Mastodon.API.Suggestions.Query?, @@ -19,12 +21,33 @@ extension APIService { let authorization = mastodonAuthenticationBox.userAuthorization return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { user in + let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } - + func recommendTrends( domain: String, query: Mastodon.API.Trends.Query? ) -> AnyPublisher, Error> { - return Mastodon.API.Trends.get(session: session, domain: domain, query: query) + Mastodon.API.Trends.get(session: session, domain: domain, query: query) } } From c74314ef11d5fa075fdeb7e7bd80cd7f0b8530cd Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 9 Apr 2021 13:59:33 +0800 Subject: [PATCH 212/400] chore: observe Follow state --- .../Section/RecommendAccountSection.swift | 9 ++-- ...hRecommendAccountsCollectionViewCell.swift | 52 +++++++++++++++++-- .../Scene/Search/SearchViewController.swift | 2 +- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index ac3feb328..409adee3e 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -18,12 +18,15 @@ enum RecommendAccountSection: Equatable, Hashable { extension RecommendAccountSection { static func collectionViewDiffableDataSource( for collectionView: UICollectionView, - managedObjectContext: NSManagedObjectContext + context: AppContext! ) -> UICollectionViewDiffableDataSource { UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell - let account = managedObjectContext.object(with: objectID) as! MastodonUser - cell.config(with: account) + let user = context.managedObjectContext.object(with: objectID) as! MastodonUser + cell.config(with: user) + if let currentUser = context.authenticationService.activeMastodonAuthentication.value?.user { + cell.configFollowButton(with: user, currentMastodonUser: currentUser) + } return cell } } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 4380d98f9..933eeb42a 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -9,8 +9,12 @@ import Foundation import MastodonSDK import UIKit import CoreDataStack +import Combine class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { + + var disposeBag = Set() + let avatarImageView: UIImageView = { let imageView = UIImageView() imageView.layer.cornerRadius = 8.4 @@ -123,16 +127,16 @@ extension SearchRecommendAccountsCollectionViewCell { ]) } - func config(with account: MastodonUser) { - displayNameLabel.text = account.displayName.isEmpty ? account.username : account.displayName - acctLabel.text = account.acct + func config(with mastodonUser: MastodonUser) { + displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName + acctLabel.text = mastodonUser.acct avatarImageView.af.setImage( - withURL: URL(string: account.avatar)!, + withURL: URL(string: mastodonUser.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) headerImageView.af.setImage( - withURL: URL(string: account.header)!, + withURL: URL(string: mastodonUser.header)!, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2)) { [weak self] _ in guard let self = self else { return } @@ -140,6 +144,44 @@ extension SearchRecommendAccountsCollectionViewCell { self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0) } } + + func configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) { + self._configFollowButton(with: mastodonUser, currentMastodonUser: currentMastodonUser) + ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { _ in + + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newUser = object as? MastodonUser else { return } + self._configFollowButton(with: mastodonUser, currentMastodonUser: newUser) + } + .store(in: &disposeBag) + } + + func _configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) { + var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow]) + + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isFollowing { + relationshipActionSet.insert(.following) + } + + let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isPending { + relationshipActionSet.insert(.pending) + } + + let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlocking { + relationshipActionSet.insert(.blocking) + } + + let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + self.followButton.setTitle(relationshipActionSet.title, for: .normal) + } } #if canImport(SwiftUI) && DEBUG diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index f697ef528..11a5630f3 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -150,7 +150,7 @@ extension SearchViewController { func setupDataSource() { viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) - viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, managedObjectContext: context.managedObjectContext) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, context: context) viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) } } From 4faacdf1beca3e19c681c5099294b2dd3d9b6490 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 9 Apr 2021 17:31:43 +0800 Subject: [PATCH 213/400] feat: implement profile infos editing --- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Generated/Assets.swift | 2 + .../Protocol/AvatarConfigurableView.swift | 15 +- .../Contents.json | 38 +++ .../Contents.json | 20 ++ ...astodonRegisterViewController+Avatar.swift | 2 +- .../MastodonRegisterViewController.swift | 9 +- .../Header/ProfileHeaderViewController.swift | 219 +++++++++++++++++- .../Header/ProfileHeaderViewModel.swift | 114 +++++++++ .../Header/View/ProfileHeaderView.swift | 190 +++++++++++++-- .../ProfileRelationshipActionButton.swift | 21 +- .../Scene/Profile/ProfileViewController.swift | 141 ++++++++--- Mastodon/Scene/Profile/ProfileViewModel.swift | 10 +- 13 files changed, 715 insertions(+), 70 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json create mode 100644 Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7e2abbc65..01b40a9c1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -224,6 +224,7 @@ DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */; }; + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */; }; DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */; }; DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */; }; DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB87D44A2609C11900D12C0D /* PollOptionView.swift */; }; @@ -600,6 +601,7 @@ DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeRepliedToStatusContentCollectionViewCell.swift; sourceTree = ""; }; + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderViewModel.swift; sourceTree = ""; }; DB8190C52601FF0400020C08 /* AttachmentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentContainerView.swift; sourceTree = ""; }; DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusPollOptionCollectionViewCell.swift; sourceTree = ""; }; DB87D44A2609C11900D12C0D /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; @@ -1719,6 +1721,7 @@ children = ( DBB525732612D5A5002F1F29 /* View */, DBB525402611ED54002F1F29 /* ProfileHeaderViewController.swift */, + DB7F48442620241000796008 /* ProfileHeaderViewModel.swift */, ); path = Header; sourceTree = ""; @@ -2250,6 +2253,7 @@ 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, + DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 6ee852bd9..7e2177348 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -86,6 +86,8 @@ internal enum Asset { } internal enum Profile { internal enum Banner { + internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray") + internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray") internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") } } diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index b8c5285a2..1c2c78da3 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -26,7 +26,7 @@ extension AvatarConfigurableView { if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { return placeholderImage .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) - .af.imageRounded(withCornerRadius: 4, divideRadiusByImageScale: true) + .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true) } else { return placeholderImage.af.imageRoundedIntoCircle() } @@ -50,11 +50,20 @@ extension AvatarConfigurableView { defer { avatarConfigurableView(self, didFinishConfiguration: configuration) } - + + let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) + // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { configurableAvatarImageView?.image = placeholderImage + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + configurableAvatarButton?.setImage(placeholderImage, for: .normal) + configurableAvatarButton?.layer.masksToBounds = true + configurableAvatarButton?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarButton?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular return } @@ -74,7 +83,6 @@ extension AvatarConfigurableView { avatarImageView.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarImageView.af.setImage( withURL: avatarImageURL, placeholderImage: placeholderImage, @@ -103,7 +111,6 @@ extension AvatarConfigurableView { avatarButton.layer.cornerRadius = Self.configurableAvatarImageCornerRadius avatarButton.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous : .circular default: - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) avatarButton.af.setImage( for: .normal, url: avatarImageURL, diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..aa5323a21 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json new file mode 100644 index 000000000..b4ce9fd5b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.360", + "blue" : "128", + "green" : "120", + "red" : "120" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index 5c5f77600..b6ba6a8af 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -42,7 +42,7 @@ extension MastodonRegisterViewController { return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) } - private func cropImage(image:UIImage,pickerViewController:UIViewController) { + private func cropImage(image: UIImage, pickerViewController: UIViewController) { DispatchQueue.main.async { let cropController = CropViewController(croppingStyle: .default, image: image) cropController.delegate = self diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 6439ea42b..2187ad52b 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -13,6 +13,9 @@ import PhotosUI import UIKit final class MastodonRegisterViewController: UIViewController, NeedsDependency, OnboardingViewControllerAppearance { + + static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) + var disposeBag = Set() weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -684,10 +687,10 @@ extension MastodonRegisterViewController { let displayName: String? = self.viewModel.displayName.value.isEmpty ? nil : self.viewModel.displayName.value let avatar: Mastodon.Query.MediaAttachment? = { guard let avatarImage = self.viewModel.avatarImage.value else { return nil } - guard avatarImage.size.width <= 400 else { - return .jpeg(avatarImage.af.imageScaled(to: CGSize(width: 400, height: 400)).jpegData(compressionQuality: 0.8)) + guard avatarImage.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return .png(avatarImage.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel).pngData()) } - return .jpeg(avatarImage.jpegData(compressionQuality: 0.8)) + return .png(avatarImage.pngData()) }() return Mastodon.API.Account.UpdateCredentialQuery( displayName: displayName, diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 4412950ac..8f382336e 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -8,6 +8,10 @@ import os.log import UIKit import Combine +import PhotosUI +import AlamofireImage +import CropViewController +import TwitterTextEditor protocol ProfileHeaderViewControllerDelegate: class { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -20,9 +24,10 @@ final class ProfileHeaderViewController: UIViewController { static let segmentedControlMarginHeight: CGFloat = 20 static let headerMinHeight: CGFloat = segmentedControlHeight + 2 * segmentedControlMarginHeight + var disposeBag = Set() weak var delegate: ProfileHeaderViewControllerDelegate? - var disposeBag = Set() + var viewModel: ProfileHeaderViewModel! let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { @@ -37,7 +42,27 @@ final class ProfileHeaderViewController: UIViewController { // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero - let needsSetupBottomShadow = CurrentValueSubject(true) + private(set) lazy var imagePicker: PHPickerViewController = { + var configuration = PHPickerConfiguration() + configuration.filter = .images + configuration.selectionLimit = 1 + + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + }() + private(set) lazy var imagePickerController: UIImagePickerController = { + let imagePickerController = UIImagePickerController() + imagePickerController.sourceType = .camera + imagePickerController.delegate = self + return imagePickerController + }() + + private(set) lazy var documentPickerController: UIDocumentPickerViewController = { + let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image]) + documentPickerController.delegate = self + return documentPickerController + }() deinit { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -73,18 +98,86 @@ extension ProfileHeaderViewController { pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) - needsSetupBottomShadow + viewModel.needsSetupBottomShadow .receive(on: DispatchQueue.main) .sink { [weak self] needsSetupBottomShadow in guard let self = self else { return } self.setupBottomShadow() } .store(in: &disposeBag) + + Publishers.CombineLatest4( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.avatarImageResource.eraseToAnyPublisher(), + viewModel.editProfileInfo.avatarImageResource.eraseToAnyPublisher(), + viewModel.viewDidAppear.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, resource, editingResource, _ in + guard let self = self else { return } + let url: URL? = { + guard case let .url(url) = resource else { return nil } + return url + + }() + let image: UIImage? = { + guard case let .image(image) = editingResource else { return nil } + return image + }() + self.profileHeaderView.configure( + with: AvatarConfigurableViewConfiguration( + avatarImageURL: image == nil ? url : nil, // set only when image empty + placeholderImage: image, + borderColor: .white, + borderWidth: 2 + ) + ) + } + .store(in: &disposeBag) + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.name.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.name.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, name, editingName in + guard let self = self else { return } + self.profileHeaderView.nameTextField.text = isEditing ? editingName : name + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.displayProfileInfo.note.removeDuplicates().eraseToAnyPublisher(), + viewModel.editProfileInfo.note.removeDuplicates().eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, note, editingNote in + guard let self = self else { return } + self.profileHeaderView.bioActiveLabel.configure(note: note ?? "") + self.profileHeaderView.bioTextEditorView.text = editingNote ?? "" + } + .store(in: &disposeBag) + + profileHeaderView.bioTextEditorView.changeObserver = self + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let self = self else { return } + guard let textField = notification.object as? UITextField else { return } + self.viewModel.editProfileInfo.name.value = textField.text + } + .store(in: &disposeBag) + + profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() + profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + viewModel.viewDidAppear.send() + // Deprecated: // not needs this tweak due to force layout update in the parent // if !isAdjustBannerImageViewForSafeAreaInset { @@ -103,6 +196,47 @@ extension ProfileHeaderViewController { } +extension ProfileHeaderViewController { + private func createAvatarContextMenu() -> UIMenu { + var children: [UIMenuElement] = [] + let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .photoLibaray", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePicker, animated: true, completion: nil) + } + children.append(photoLibraryAction) + if UIImagePickerController.isSourceTypeAvailable(.camera) { + let cameraAction = UIAction(title: L10n.Scene.Compose.MediaSelection.camera, image: UIImage(systemName: "camera"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .camera", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.imagePickerController, animated: true, completion: nil) + }) + children.append(cameraAction) + } + let browseAction = UIAction(title: L10n.Scene.Compose.MediaSelection.browse, image: UIImage(systemName: "ellipsis"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: mediaSelectionType: .browse", ((#file as NSString).lastPathComponent), #line, #function) + self.present(self.documentPickerController, animated: true, completion: nil) + } + children.append(browseAction) + + return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) + } + + private func cropImage(image: UIImage, pickerViewController: UIViewController) { + DispatchQueue.main.async { + let cropController = CropViewController(croppingStyle: .default, image: image) + cropController.delegate = self + cropController.setAspectRatioPreset(.presetSquare, animated: true) + cropController.aspectRatioPickerButtonHidden = true + cropController.aspectRatioLockEnabled = true + pickerViewController.dismiss(animated: true, completion: { + self.present(cropController, animated: true, completion: nil) + }) + } + } +} + extension ProfileHeaderViewController { @objc private func pageSegmentedControlValueChanged(_ sender: UISegmentedControl) { @@ -119,7 +253,7 @@ extension ProfileHeaderViewController { } func setupBottomShadow() { - guard needsSetupBottomShadow.value else { + guard viewModel.needsSetupBottomShadow.value else { view.layer.shadowColor = nil view.layer.shadowRadius = 0 return @@ -164,3 +298,80 @@ extension ProfileHeaderViewController { } } + +// MARK: - TextEditorViewChangeObserver +extension ProfileHeaderViewController: TextEditorViewChangeObserver { + func textEditorView(_ textEditorView: TextEditorView, didChangeWithChangeResult changeResult: TextEditorViewChangeResult) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: text: %s", ((#file as NSString).lastPathComponent), #line, #function, textEditorView.text) + guard changeResult.isTextChanged else { return } + assert(textEditorView === profileHeaderView.bioTextEditorView) + viewModel.editProfileInfo.note.value = textEditorView.text + } +} + +// MARK: - PHPickerViewControllerDelegate +extension ProfileHeaderViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true, completion: nil) + guard let result = results.first else { return } + PHPickerResultLoader.loadImageData(from: result) + .sink { [weak self] completion in + guard let _ = self else { return } + switch completion { + case .failure: + // TODO: handle error + break + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + guard let imageData = imageData else { return } + guard let image = UIImage(data: imageData) else { return } + self.cropImage(image: image, pickerViewController: picker) + } + .store(in: &disposeBag) + } +} + +// MARK: - UIImagePickerControllerDelegate +extension ProfileHeaderViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + picker.dismiss(animated: true, completion: nil) + + guard let image = info[.originalImage] as? UIImage else { return } + cropImage(image: image, pickerViewController: picker) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + picker.dismiss(animated: true, completion: nil) + } +} + +// MARK: - UIDocumentPickerDelegate +extension ProfileHeaderViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { return } + + do { + guard url.startAccessingSecurityScopedResource() else { return } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + guard let image = UIImage(data: imageData) else { return } + cropImage(image: image, pickerViewController: controller) + } catch { + os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + } +} + +// MARK: - CropViewControllerDelegate +extension ProfileHeaderViewController: CropViewControllerDelegate { + public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + viewModel.editProfileInfo.avatarImageResource.value = .image(image) + cropViewController.dismiss(animated: true, completion: nil) + } +} + diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift new file mode 100644 index 000000000..be0676740 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -0,0 +1,114 @@ +// +// ProfileHeaderViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-9. +// + +import UIKit +import Combine +import Kanna +import MastodonSDK + +final class ProfileHeaderViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let isEditing = CurrentValueSubject(false) + let viewDidAppear = PassthroughSubject() + let needsSetupBottomShadow = CurrentValueSubject(true) + + // output + let displayProfileInfo = ProfileInfo() + let editProfileInfo = ProfileInfo() + + init(context: AppContext) { + self.context = context + + isEditing + .removeDuplicates() // only triiger when value toggle + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing in + guard let self = self else { return } + // setup editing value when toggle to editing + self.editProfileInfo.name.value = self.displayProfileInfo.name.value // set to name + self.editProfileInfo.avatarImageResource.value = .image(nil) // set to empty + self.editProfileInfo.note.value = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note.value) + } + .store(in: &disposeBag) + } + +} + +extension ProfileHeaderViewModel { + struct ProfileInfo { + let name = CurrentValueSubject(nil) + let avatarImageResource = CurrentValueSubject(nil) + let note = CurrentValueSubject(nil) + + enum ImageResource { + case url(URL?) + case image(UIImage?) + } + } +} + +extension ProfileHeaderViewModel { + + static func normalize(note: String?) -> String? { + guard let note = note?.trimmingCharacters(in: .whitespacesAndNewlines),!note.isEmpty else { + return nil + } + + let html = try? HTML(html: note, encoding: .utf8) + return html?.text + } + + // check if profile chagned or not + func isProfileInfoEdited() -> Bool { + guard isEditing.value else { return false } + + guard editProfileInfo.name.value == displayProfileInfo.name.value else { return true } + guard case let .image(image) = editProfileInfo.avatarImageResource.value, image == nil else { return true } + guard editProfileInfo.note.value == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note.value) else { return true } + + return false + } + + func updateProfileInfo() -> AnyPublisher, Error> { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return Fail(error: APIService.APIError.implicit(.badRequest)).eraseToAnyPublisher() + } + let domain = activeMastodonAuthenticationBox.domain + let authorization = activeMastodonAuthenticationBox.userAuthorization + + let image: UIImage? = { + guard case let .image(_image) = editProfileInfo.avatarImageResource.value else { return nil } + guard let image = _image else { return nil } + guard image.size.width <= MastodonRegisterViewController.avatarImageMaxSizeInPixel.width else { + return image.af.imageScaled(to: MastodonRegisterViewController.avatarImageMaxSizeInPixel) + } + return image + }() + + let query = Mastodon.API.Account.UpdateCredentialQuery( + discoverable: nil, + bot: nil, + displayName: editProfileInfo.name.value, + note: editProfileInfo.note.value, + avatar: image.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + header: nil, + locked: nil, + source: nil, + fieldsAttributes: nil // TODO: + ) + return context.apiService.accountUpdateCredentials( + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index bf292ac45..2fba55e66 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -8,6 +8,7 @@ import os.log import UIKit import ActiveLabel +import TwitterTextEditor protocol ProfileHeaderViewDelegate: class { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) @@ -25,8 +26,13 @@ final class ProfileHeaderView: UIView { static let friendshipActionButtonSize = CGSize(width: 108, height: 34) static let bannerImageViewPlaceholderColor = UIColor.systemGray + static let bannerImageViewOverlayViewBackgroundNormalColor = UIColor.black.withAlphaComponent(0.5) + static let bannerImageViewOverlayViewBackgroundEditingColor = UIColor.black.withAlphaComponent(0.8) + weak var delegate: ProfileHeaderViewDelegate? + var state: State? + let bannerContainerView = UIView() let bannerImageView: UIImageView = { let imageView = UIImageView() @@ -41,7 +47,7 @@ final class ProfileHeaderView: UIView { }() let bannerImageViewOverlayView: UIView = { let overlayView = UIView() - overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor return overlayView }() @@ -53,16 +59,40 @@ final class ProfileHeaderView: UIView { imageView.image = placeholderImage return imageView }() + + let editAvatarBackgroundView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.black.withAlphaComponent(0.6) + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + return view + }() + + let editAvatarButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) + button.tintColor = .white + return button + }() - let nameLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.5 - label.textColor = .white - label.text = "Alice" - label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) - return label + let nameTextFieldBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerCurve = .continuous + view.layer.cornerRadius = 10 + return view + }() + + let nameTextField: UITextField = { + let textField = UITextField() + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + textField.textColor = .white + textField.text = "Alice" + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) + return textField }() let usernameLabel: UILabel = { @@ -84,9 +114,29 @@ final class ProfileHeaderView: UIView { }() let bioContainerView = UIView() + let bioContainerStackView = UIStackView() let fieldContainerStackView = UIStackView() + let bioActiveLabelContainer: UIView = { + // use to set margin for active label + // the display/edit mode bio transition animation should without flicker with that + let view = UIView() + // note: comment out to see how it works + view.layoutMargins = UIEdgeInsets(top: 8, left: 5, bottom: 8, right: 5) // magic from TextEditorView + return view + }() let bioActiveLabel = ActiveLabel(style: .default) + let bioTextEditorView: TextEditorView = { + let textEditorView = TextEditorView() + textEditorView.scrollView.isScrollEnabled = false + textEditorView.isScrollEnabled = false + textEditorView.font = .preferredFont(forTextStyle: .body) + textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + textEditorView.layer.masksToBounds = true + textEditorView.layer.cornerCurve = .continuous + textEditorView.layer.cornerRadius = 10 + return textEditorView + }() override init(frame: CGRect) { super.init(frame: frame) @@ -137,12 +187,32 @@ extension ProfileHeaderView { avatarImageView.widthAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.width).priority(.required - 1), avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) + + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.addSubview(editAvatarBackgroundView) + NSLayoutConstraint.activate([ + editAvatarBackgroundView.topAnchor.constraint(equalTo: avatarImageView.topAnchor), + editAvatarBackgroundView.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor), + editAvatarBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), + editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor), + ]) + + editAvatarButton.translatesAutoresizingMaskIntoConstraints = false + editAvatarBackgroundView.addSubview(editAvatarButton) + NSLayoutConstraint.activate([ + editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), + editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), + editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), + editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), + ]) + editAvatarBackgroundView.isUserInteractionEnabled = true + avatarImageView.isUserInteractionEnabled = true - // name container: [display name | username] + // name container: [display name container | username] let nameContainerStackView = UIStackView() nameContainerStackView.preservesSuperviewLayoutMargins = true nameContainerStackView.axis = .vertical - nameContainerStackView.spacing = 0 + nameContainerStackView.spacing = 7 nameContainerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(nameContainerStackView) NSLayoutConstraint.activate([ @@ -150,7 +220,27 @@ extension ProfileHeaderView { nameContainerStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), nameContainerStackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), ]) - nameContainerStackView.addArrangedSubview(nameLabel) + + let displayNameStackView = UIStackView() + displayNameStackView.axis = .horizontal + nameTextField.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addArrangedSubview(nameTextField) + NSLayoutConstraint.activate([ + nameTextField.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).priority(.defaultHigh), + ]) + nameTextField.setContentHuggingPriority(.defaultHigh, for: .horizontal) + nameTextFieldBackgroundView.translatesAutoresizingMaskIntoConstraints = false + displayNameStackView.addSubview(nameTextFieldBackgroundView) + NSLayoutConstraint.activate([ + nameTextField.topAnchor.constraint(equalTo: nameTextFieldBackgroundView.topAnchor, constant: 5), + nameTextField.leadingAnchor.constraint(equalTo: nameTextFieldBackgroundView.leadingAnchor, constant: 5), + nameTextFieldBackgroundView.bottomAnchor.constraint(equalTo: nameTextField.bottomAnchor, constant: 5), + nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameTextField.trailingAnchor, constant: 5), + ]) + displayNameStackView.bringSubviewToFront(nameTextField) + displayNameStackView.addArrangedSubview(UIView()) + + nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) // meta container: [dashboard container | bio container | field container] @@ -192,15 +282,29 @@ extension ProfileHeaderView { bioContainerView.preservesSuperviewLayoutMargins = true metaContainerStackView.addArrangedSubview(bioContainerView) - bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false - bioContainerView.addSubview(bioActiveLabel) + + bioContainerStackView.translatesAutoresizingMaskIntoConstraints = false + bioContainerView.addSubview(bioContainerStackView) NSLayoutConstraint.activate([ - bioActiveLabel.topAnchor.constraint(equalTo: bioContainerView.topAnchor), - bioActiveLabel.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), - bioActiveLabel.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), - bioActiveLabel.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), + bioContainerStackView.topAnchor.constraint(equalTo: bioContainerView.topAnchor), + bioContainerStackView.leadingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.leadingAnchor), + bioContainerStackView.trailingAnchor.constraint(equalTo: bioContainerView.readableContentGuide.trailingAnchor), + bioContainerStackView.bottomAnchor.constraint(equalTo: bioContainerView.bottomAnchor), ]) + bioActiveLabel.translatesAutoresizingMaskIntoConstraints = false + bioActiveLabelContainer.addSubview(bioActiveLabel) + NSLayoutConstraint.activate([ + bioActiveLabel.topAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.topAnchor), + bioActiveLabel.leadingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.leadingAnchor), + bioActiveLabel.trailingAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.trailingAnchor), + bioActiveLabel.bottomAnchor.constraint(equalTo: bioActiveLabelContainer.layoutMarginsGuide.bottomAnchor), + ]) + + bioContainerStackView.axis = .vertical + bioContainerStackView.addArrangedSubview(bioActiveLabelContainer) + bioContainerStackView.addArrangedSubview(bioTextEditorView) + fieldContainerStackView.preservesSuperviewLayoutMargins = true metaContainerStackView.addSubview(fieldContainerStackView) @@ -210,10 +314,58 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) + + configure(state: .normal) } } +extension ProfileHeaderView { + enum State { + case normal + case editing + } + + func configure(state: State) { + guard self.state != state else { return } // avoid redundant animation + self.state = state + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + + switch state { + case .normal: + nameTextField.isEnabled = false + bioActiveLabelContainer.isHidden = false + bioTextEditorView.isHidden = true + + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editAvatarBackgroundView.alpha = 0 + } + animator.addCompletion { _ in + self.editAvatarBackgroundView.isHidden = true + } + case .editing: + nameTextField.isEnabled = true + bioActiveLabelContainer.isHidden = true + bioTextEditorView.isHidden = false + + editAvatarBackgroundView.isHidden = false + editAvatarBackgroundView.alpha = 0 + bioTextEditorView.backgroundColor = .clear + animator.addAnimations { + self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor + self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color + self.editAvatarBackgroundView.alpha = 1 + self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + } + } + + animator.startAnimation() + } +} + extension ProfileHeaderView { @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index 70e6e1647..d4b57ffe4 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -9,6 +9,12 @@ import UIKit final class ProfileRelationshipActionButton: RoundedEdgesButton { + let actvityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + return activityIndicatorView + }() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -23,7 +29,15 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton { extension ProfileRelationshipActionButton { private func _init() { - // do nothing + actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(actvityIndicatorView) + NSLayoutConstraint.activate([ + actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + actvityIndicatorView.hidesWhenStopped = true + actvityIndicatorView.stopAnimating() } } @@ -36,8 +50,13 @@ extension ProfileRelationshipActionButton { setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) + actvityIndicatorView.stopAnimating() + if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { isEnabled = false + } else if actionOptionSet.contains(.updating) { + isEnabled = false + actvityIndicatorView.startAnimating() } else { isEnabled = true } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index f070f72c6..75bd04dc9 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,6 +18,12 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { + let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) + barButtonItem.tintColor = .white + return barButtonItem + }() + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "gear"), style: .plain, target: self, action: #selector(ProfileViewController.settingBarButtonItemPressed(_:))) barButtonItem.tintColor = .white @@ -72,7 +78,11 @@ final class ProfileViewController: UIViewController, NeedsDependency { }() private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() - private(set) lazy var profileHeaderViewController = ProfileHeaderViewController() + private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { + let viewController = ProfileHeaderViewController() + viewController.viewModel = ProfileHeaderViewModel(context: context) + return viewController + }() private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! private var contentOffsets: [Int: CGFloat] = [:] @@ -136,15 +146,38 @@ extension ProfileViewController { navigationItem.titleView = UIView() - Publishers.CombineLatest4( - viewModel.suspended.eraseToAnyPublisher(), + let editingAndUpdatingPublisher = Publishers.CombineLatest( + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() + ) + .share() + + editingAndUpdatingPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] isEditing, isUpdating in + guard let self = self else { return } + self.cancelEditingBarButtonItem.isEnabled = !isUpdating + } + .store(in: &disposeBag) + + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() ) + .share() + + Publishers.CombineLatest3 ( + viewModel.suspended.eraseToAnyPublisher(), + editingAndUpdatingPublisher.eraseToAnyPublisher(), + barButtonItemHiddenPublisher.eraseToAnyPublisher() + ) .receive(on: DispatchQueue.main) - .sink { [weak self] suspended, isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden in + .sink { [weak self] suspended, tuple1, tuple2 in guard let self = self else { return } + let (isEditing, _) = tuple1 + let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 + var items: [UIBarButtonItem] = [] defer { self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil @@ -154,6 +187,11 @@ extension ProfileViewController { return } + guard !isEditing else { + items.append(self.cancelEditingBarButtonItem) + return + } + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) @@ -293,22 +331,15 @@ extension ProfileViewController { ) } .store(in: &disposeBag) - Publishers.CombineLatest( - viewModel.avatarImageURL.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] avatarImageURL, _ in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.configure( - with: AvatarConfigurableViewConfiguration(avatarImageURL: avatarImageURL, borderColor: .white, borderWidth: 2) - ) - } - .store(in: &disposeBag) - viewModel.name - .map { $0 ?? " " } + viewModel.avatarImageURL .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.nameLabel) + .map { url in ProfileHeaderViewModel.ProfileInfo.ImageResource.url(url) } + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.avatarImageResource) + .store(in: &disposeBag) + viewModel.name + .map { $0 ?? "" } + .receive(on: DispatchQueue.main) + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.name) .store(in: &disposeBag) viewModel.username .map { username in username.flatMap { "@" + $0 } ?? " " } @@ -336,21 +367,41 @@ extension ProfileViewController { self.profileHeaderViewController.profileHeaderView.relationshipActionButton.isHidden = isHidden } .store(in: &disposeBag) - Publishers.CombineLatest( + Publishers.CombineLatest3( viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), - viewModel.isEditing.eraseToAnyPublisher() + viewModel.isEditing.eraseToAnyPublisher(), + viewModel.isUpdating.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionSet, isEditing in + .sink { [weak self] relationshipActionSet, isEditing, isUpdating in guard let self = self else { return } let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton if relationshipActionSet.contains(.edit) { - friendshipButton.configure(actionOptionSet: isEditing ? .editing : .edit) + // check .edit state and set .editing when isEditing + friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) + self.profileHeaderViewController.profileHeaderView.configure(state: isUpdating || isEditing ? .editing : .normal) } else { friendshipButton.configure(actionOptionSet: relationshipActionSet) } } .store(in: &disposeBag) + viewModel.isEditing + .handleEvents(receiveOutput: { [weak self] isEditing in + guard let self = self else { return } + // dismiss keyboard if needs + if !isEditing { self.view.endEditing(true) } + + self.profileHeaderViewController.pageSegmentedControl.isEnabled = !isEditing + self.profileSegmentedViewController.view.isUserInteractionEnabled = !isEditing + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0 + } + animator.startAnimation() + }) + .assign(to: \.value, on: profileHeaderViewController.viewModel.isEditing) + .store(in: &disposeBag) Publishers.CombineLatest3( viewModel.isBlocking.eraseToAnyPublisher(), viewModel.isBlockedBy.eraseToAnyPublisher(), @@ -360,7 +411,7 @@ extension ProfileViewController { .sink { [weak self] isBlocking, isBlockedBy, suspended in guard let self = self else { return } let isNeedSetHidden = isBlocking || isBlockedBy || suspended - self.profileHeaderViewController.needsSetupBottomShadow.value = !isNeedSetHidden + self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden self.profileHeaderViewController.pageSegmentedControl.isHidden = isNeedSetHidden self.viewModel.needsPagePinToTop.value = isNeedSetHidden @@ -368,10 +419,7 @@ extension ProfileViewController { .store(in: &disposeBag) viewModel.bioDescription .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] bio in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.bioActiveLabel.configure(note: bio ?? "") - }) + .assign(to: \.value, on: profileHeaderViewController.viewModel.displayProfileInfo.note) .store(in: &disposeBag) viewModel.statusesCount .sink { [weak self] count in @@ -420,6 +468,7 @@ extension ProfileViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + currentPostTimelineTableViewContentSizeObservation = nil } @@ -440,6 +489,11 @@ extension ProfileViewController { extension ProfileViewController { + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + viewModel.isEditing.value = false + } + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -488,11 +542,6 @@ extension ProfileViewController { sender.endRefreshing() } } - -// @objc private func avatarButtonPressed(_ sender: UIButton) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// coordinator.present(scene: .drawerSidebar, from: self, transition: .custom(transitioningDelegate: drawerSidebarTransitionController)) -// } } @@ -571,7 +620,29 @@ extension ProfileViewController: ProfileHeaderViewDelegate { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { - viewModel.isEditing.value.toggle() + guard !viewModel.isUpdating.value else { return } + + if profileHeaderViewController.viewModel.isProfileInfoEdited() { + viewModel.isUpdating.value = true + profileHeaderViewController.viewModel.updateProfileInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update profile info success", ((#file as NSString).lastPathComponent), #line, #function) + } + self.viewModel.isUpdating.value = false + } receiveValue: { [weak self] _ in + guard let self = self else { return } + self.viewModel.isEditing.value = false + } + .store(in: &disposeBag) + } else { + viewModel.isEditing.value.toggle() + } } else { guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } switch relationshipAction { @@ -634,9 +705,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { default: assertionFailure() } - } - } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) { diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 7db38d33c..445952e96 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -43,8 +43,10 @@ class ProfileViewModel: NSObject { let protected: CurrentValueSubject let suspended: CurrentValueSubject - let relationshipActionOptionSet = CurrentValueSubject(.none) let isEditing = CurrentValueSubject(false) + let isUpdating = CurrentValueSubject(false) + + let relationshipActionOptionSet = CurrentValueSubject(.none) let isFollowedBy = CurrentValueSubject(false) let isMuting = CurrentValueSubject(false) let isBlocking = CurrentValueSubject(false) @@ -328,6 +330,7 @@ extension ProfileViewModel { case suspended case edit case editing + case updating var option: RelationshipActionOptionSet { return RelationshipActionOptionSet(rawValue: 1 << rawValue) @@ -349,8 +352,9 @@ extension ProfileViewModel { static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option + static let updating = RelationshipAction.updating.option - static let editOptions: RelationshipActionOptionSet = [.edit, .editing] + static let editOptions: RelationshipActionOptionSet = [.edit, .editing, .updating] func highPriorityAction(except: RelationshipActionOptionSet) -> RelationshipAction? { let set = subtracting(except) @@ -378,6 +382,7 @@ extension ProfileViewModel { case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done + case .updating: return " " } } @@ -398,6 +403,7 @@ extension ProfileViewModel { case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color + case .updating: return Asset.Colors.Button.normal.color } } From 9184ec4ecfd39cf711ad4e2fb6da897114fa4ee9 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 9 Apr 2021 17:46:20 +0800 Subject: [PATCH 214/400] fix: combine event consumed issue --- .../Scene/Profile/ProfileViewController.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 75bd04dc9..9819f3f18 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -150,7 +150,13 @@ extension ProfileViewController { viewModel.isEditing.eraseToAnyPublisher(), viewModel.isUpdating.eraseToAnyPublisher() ) - .share() + // note: not add .share() here + + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( + viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), + viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), + viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() + ) editingAndUpdatingPublisher .receive(on: DispatchQueue.main) @@ -159,13 +165,6 @@ extension ProfileViewController { self.cancelEditingBarButtonItem.isEnabled = !isUpdating } .store(in: &disposeBag) - - let barButtonItemHiddenPublisher = Publishers.CombineLatest3( - viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), - viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), - viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() - ) - .share() Publishers.CombineLatest3 ( viewModel.suspended.eraseToAnyPublisher(), From 567c2af0eea50833e15a003b19f4bd68e72eddeb Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 9 Apr 2021 19:39:35 +0800 Subject: [PATCH 215/400] chore: add followAction --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/RecommendAccountSection.swift | 15 +- ...hRecommendAccountsCollectionViewCell.swift | 60 +++----- .../Search/SearchViewController+Follow.swift | 137 ++++++++++++++++++ .../Scene/Search/SearchViewController.swift | 2 +- Mastodon/Scene/Search/SearchViewModel.swift | 16 ++ 6 files changed, 185 insertions(+), 49 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchViewController+Follow.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 01d5f1f20..fda5de623 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -135,6 +135,7 @@ 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */; }; 5DF1057F25F88A4100D6C0D4 /* TouchBlockingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */; }; 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */; }; + 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; @@ -506,6 +507,7 @@ 5DF1057825F88A1D00D6C0D4 /* PlayerContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerContainerView.swift; sourceTree = ""; }; 5DF1057E25F88A4100D6C0D4 /* TouchBlockingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchBlockingView.swift; sourceTree = ""; }; 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; + 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1613,6 +1615,7 @@ DB9D6BE825E4F5340051B173 /* SearchViewController.swift */, 2D34D9CA261489930081BFC0 /* SearchViewController+Recommend.swift */, 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */, + 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */, 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */, 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */, 2D34D9E026149C550081BFC0 /* CollectionViewCell */, @@ -2355,6 +2358,7 @@ 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, 2DF75BA725D10E1000694EC8 /* APIService+Favorite.swift in Sources */, DB9D6C3825E508BE0051B173 /* Attachment.swift in Sources */, + 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */, DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 409adee3e..3ecd4e3b2 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -5,11 +5,11 @@ // Created by sxiaojian on 2021/4/1. // +import CoreData +import CoreDataStack import Foundation import MastodonSDK import UIKit -import CoreData -import CoreDataStack enum RecommendAccountSection: Equatable, Hashable { case main @@ -18,15 +18,14 @@ enum RecommendAccountSection: Equatable, Hashable { extension RecommendAccountSection { static func collectionViewDiffableDataSource( for collectionView: UICollectionView, - context: AppContext! + delegate: SearchRecommendAccountsCollectionViewCellDelegate, + managedObjectContext: NSManagedObjectContext ) -> UICollectionViewDiffableDataSource { - UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, objectID -> UICollectionViewCell? in + UICollectionViewDiffableDataSource(collectionView: collectionView) { [weak delegate] collectionView, indexPath, objectID -> UICollectionViewCell? in let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self), for: indexPath) as! SearchRecommendAccountsCollectionViewCell - let user = context.managedObjectContext.object(with: objectID) as! MastodonUser + let user = managedObjectContext.object(with: objectID) as! MastodonUser + cell.delegate = delegate cell.config(with: user) - if let currentUser = context.authenticationService.activeMastodonAuthentication.value?.user { - cell.configFollowButton(with: user, currentMastodonUser: currentUser) - } return cell } } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 933eeb42a..c64db4981 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -5,16 +5,23 @@ // Created by sxiaojian on 2021/4/1. // +import Combine +import CoreDataStack import Foundation import MastodonSDK import UIKit -import CoreDataStack -import Combine + +protocol SearchRecommendAccountsCollectionViewCellDelegate: NSObject { + func followButtonDidPressed(clickedUser: MastodonUser) + + func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton) +} class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { - var disposeBag = Set() + weak var delegate: SearchRecommendAccountsCollectionViewCellDelegate? + let avatarImageView: UIImageView = { let imageView = UIImageView() imageView.layer.cornerRadius = 8.4 @@ -52,8 +59,8 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { return label }() - let followButton: UIButton = { - let button = UIButton(type: .custom) + let followButton: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) button.setTitleColor(.white, for: .normal) button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) @@ -138,49 +145,22 @@ extension SearchRecommendAccountsCollectionViewCell { headerImageView.af.setImage( withURL: URL(string: mastodonUser.header)!, placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2)) { [weak self] _ in + imageTransition: .crossDissolve(0.2) + ) { [weak self] _ in guard let self = self else { return } self.headerImageView.addSubview(self.visualEffectView) self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0) } - } - - func configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) { - self._configFollowButton(with: mastodonUser, currentMastodonUser: currentMastodonUser) - ManagedObjectObserver.observe(object: currentMastodonUser) - .sink { _ in - - } receiveValue: { change in - guard case .update(let object) = change.changeType, - let newUser = object as? MastodonUser else { return } - self._configFollowButton(with: mastodonUser, currentMastodonUser: newUser) + delegate?.configFollowButton(with: mastodonUser, followButton: followButton) + followButton.publisher(for: .touchUpInside) + .sink { [weak self] _ in + self?.followButtonDidPressed(mastodonUser: mastodonUser) } .store(in: &disposeBag) } - func _configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) { - var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow]) - - let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isFollowing { - relationshipActionSet.insert(.following) - } - - let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isPending { - relationshipActionSet.insert(.pending) - } - - let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - if isBlocking { - relationshipActionSet.insert(.blocking) - } - - let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false - if isBlockedBy { - relationshipActionSet.insert(.blocked) - } - self.followButton.setTitle(relationshipActionSet.title, for: .normal) + func followButtonDidPressed(mastodonUser: MastodonUser) { + delegate?.followButtonDidPressed(clickedUser: mastodonUser) } } diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift new file mode 100644 index 000000000..ce7d13b8a --- /dev/null +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -0,0 +1,137 @@ +// +// SearchViewController+Follow.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/9. +// + +import Combine +import CoreDataStack +import Foundation +import UIKit + +extension SearchViewController: UserProvider { + func mastodonUser() -> Future { + Future { promise in + promise(.success(self.viewModel.mastodonUser.value)) + } + } +} + +extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegate { + func followButtonDidPressed(clickedUser: MastodonUser) { + viewModel.mastodonUser.value = clickedUser + guard let currentMastodonUser = viewModel.currentMastodonUser.value else { + return + } + let relationshipAction = relationShipActionSet(mastodonUser: clickedUser, currentMastodonUser: currentMastodonUser) + switch relationshipAction { + case .none: + break + case .follow, .following: + UserProviderFacade.toggleUserFollowRelationship(provider: self) + .sink { _ in + + } receiveValue: { _ in + } + .store(in: &disposeBag) + case .pending: + break + case .muting: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), + preferredStyle: .alert + ) + let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserMuteRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unmuteAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocking: + guard let mastodonUser = viewModel.mastodonUser.value else { return } + let name = mastodonUser.displayNameWithFallback + let alertController = UIAlertController( + title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.title, + message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.message(name), + preferredStyle: .alert + ) + let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in + guard let self = self else { return } + UserProviderFacade.toggleUserBlockRelationship(provider: self) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &self.context.disposeBag) + } + alertController.addAction(unblockAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + case .blocked: + break + default: + assertionFailure() + } + } + + func configFollowButton(with mastodonUser: MastodonUser, followButton: HighlightDimmableButton) { + guard let currentMastodonUser = viewModel.currentMastodonUser.value else { + return + } + _configFollowButton(with: mastodonUser, currentMastodonUser: currentMastodonUser, followButton: followButton) + ManagedObjectObserver.observe(object: currentMastodonUser) + .sink { _ in + + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newUser = object as? MastodonUser else { return } + self._configFollowButton(with: mastodonUser, currentMastodonUser: newUser, followButton: followButton) + } + .store(in: &disposeBag) + } +} + +extension SearchViewController { + func _configFollowButton(with mastodonUser: MastodonUser, currentMastodonUser: MastodonUser, followButton: HighlightDimmableButton) { + let relationshipActionSet = relationShipActionSet(mastodonUser: mastodonUser, currentMastodonUser: currentMastodonUser) + followButton.setTitle(relationshipActionSet.title, for: .normal) + } + + func relationShipActionSet(mastodonUser: MastodonUser, currentMastodonUser: MastodonUser) -> ProfileViewModel.RelationshipActionOptionSet { + var relationshipActionSet = ProfileViewModel.RelationshipActionOptionSet([.follow]) + let isFollowing = mastodonUser.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isFollowing { + relationshipActionSet.insert(.following) + } + + let isPending = mastodonUser.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isPending { + relationshipActionSet.insert(.pending) + } + + let isBlocking = mastodonUser.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlocking { + relationshipActionSet.insert(.blocking) + } + + let isBlockedBy = currentMastodonUser.blockingBy.flatMap { $0.contains(mastodonUser) } ?? false + if isBlockedBy { + relationshipActionSet.insert(.blocked) + } + return relationshipActionSet + } +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 11a5630f3..f3e2e0f0d 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -150,7 +150,7 @@ extension SearchViewController { func setupDataSource() { viewModel.hashtagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashtagCollectionView) - viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, context: context) + viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView, delegate: self, managedObjectContext: context.managedObjectContext) viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self) } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index c3a5987ba..3313d1760 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -21,6 +21,9 @@ final class SearchViewModel: NSObject { let context: AppContext weak var coordinator: SceneCoordinator! + let mastodonUser = CurrentValueSubject(nil) + let currentMastodonUser = CurrentValueSubject(nil) + // output let searchText = CurrentValueSubject("") let searchScope = CurrentValueSubject(Mastodon.API.Search.SearchType.default) @@ -60,6 +63,19 @@ final class SearchViewModel: NSObject { guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + // bind active authentication + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.currentMastodonUser.value = nil + return + } + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + Publishers.CombineLatest( searchText .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), From ff13121b186fcff53afeb811f43997cc0df3a242 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 9 Apr 2021 19:44:48 +0800 Subject: [PATCH 216/400] feat: add title view for profile scene --- Localization/app.json | 1 + Mastodon/Generated/Strings.swift | 4 ++ .../Resources/en.lproj/Localizable.strings | 1 + .../Header/ProfileHeaderViewController.swift | 64 ++++++++++++++++++- .../Header/ProfileHeaderViewModel.swift | 5 +- .../Scene/Profile/ProfileViewController.swift | 26 +++++++- README.md | 1 + 7 files changed, 95 insertions(+), 7 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index a43e56c10..f29c588c2 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -266,6 +266,7 @@ } }, "profile": { + "subtitle": "%s posts", "dashboard": { "posts": "posts", "following": "following", diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7e50253b4..da27f7a67 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -340,6 +340,10 @@ internal enum L10n { } } internal enum Profile { + /// %@ posts + internal static func subtitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Profile.Subtitle", String(describing: p1)) + } internal enum Dashboard { /// followers internal static let followers = L10n.tr("Localizable", "Scene.Profile.Dashboard.Followers") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 64b62233f..e94101cc4 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -124,6 +124,7 @@ tap the link to confirm your account."; "Scene.Profile.SegmentedControl.Media" = "Media"; "Scene.Profile.SegmentedControl.Posts" = "Posts"; "Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Profile.Subtitle" = "%@ posts"; "Scene.PublicTimeline.Title" = "Public"; "Scene.Register.Error.Item.Agreement" = "Agreement"; "Scene.Register.Error.Item.Email" = "Email"; diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 8f382336e..38695f34f 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -29,6 +29,16 @@ final class ProfileHeaderViewController: UIViewController { var viewModel: ProfileHeaderViewModel! + let titleView: DoubleTitleLabelNavigationBarTitleView = { + let titleView = DoubleTitleLabelNavigationBarTitleView() + titleView.titleLabel.textColor = .white + titleView.titleLabel.alpha = 0 + titleView.subtitleLabel.textColor = .white + titleView.subtitleLabel.alpha = 0 + titleView.layer.masksToBounds = true + return titleView + }() + let profileHeaderView = ProfileHeaderView() let pageSegmentedControl: UISegmentedControl = { let segmenetedControl = UISegmentedControl(items: ["A", "B"]) @@ -97,6 +107,18 @@ extension ProfileHeaderViewController { ]) pageSegmentedControl.addTarget(self, action: #selector(ProfileHeaderViewController.pageSegmentedControlValueChanged(_:)), for: .valueChanged) + + Publishers.CombineLatest( + viewModel.viewDidAppear.eraseToAnyPublisher(), + viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSetted in + guard let self = self else { return } + self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSetted ? 1 : 0 + } + .store(in: &disposeBag) viewModel.needsSetupBottomShadow .receive(on: DispatchQueue.main) @@ -176,7 +198,7 @@ extension ProfileHeaderViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.send() + viewModel.viewDidAppear.value = true // Deprecated: // not needs this tweak due to force layout update in the parent @@ -281,20 +303,56 @@ extension ProfileHeaderViewController { let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height - + + // scroll from bottom to top: 1 -> 2 -> 3 if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { + // 1 + // banner top pin to window top and expand bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height } else if bannerContainerBottomOffset < containerSafeAreaInset.top { + // 3 + // banner bottom pin to navigation bar bottom and + // the `progress` growth to 1 then segemented control pin to top bannerImageView.frame.origin.y = -containerSafeAreaInset.top let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) bannerImageView.frame.size.height = bannerImageHeight } else { + // 2 + // banner move with scrolling from bottom to top until the + // banner bottom higher than navigation bar bottom bannerImageView.frame.origin.y = -containerSafeAreaInset.top bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top } - // TODO: handle titleView + // set title view offset + let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) + let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y + let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset + titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset)) + + if viewModel.viewDidAppear.value { + viewModel.isTitleViewContentOffsetSet.value = true + } + + // set avatar + if progress > 0 { + setProfileBannerFade(alpha: 0) + } else if progress > -0.3 { + // y = -(10/3)x + let alpha = -10.0 / 3.0 * progress + setProfileBannerFade(alpha: alpha) + } else { + setProfileBannerFade(alpha: 1) + } + } + + private func setProfileBannerFade(alpha: CGFloat) { + profileHeaderView.avatarImageView.alpha = alpha + profileHeaderView.editAvatarBackgroundView.alpha = alpha + profileHeaderView.nameTextFieldBackgroundView.alpha = alpha + profileHeaderView.nameTextField.alpha = alpha + profileHeaderView.usernameLabel.alpha = alpha } } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index be0676740..eb4a054b8 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -17,9 +17,10 @@ final class ProfileHeaderViewModel { // input let context: AppContext let isEditing = CurrentValueSubject(false) - let viewDidAppear = PassthroughSubject() + let viewDidAppear = CurrentValueSubject(false) let needsSetupBottomShadow = CurrentValueSubject(true) - + let isTitleViewContentOffsetSet = CurrentValueSubject(false) + // output let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 9819f3f18..671f7c155 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -88,6 +88,10 @@ final class ProfileViewController: UIViewController, NeedsDependency { private var contentOffsets: [Int: CGFloat] = [:] var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + // title view nested in header + var titleView: DoubleTitleLabelNavigationBarTitleView { + profileHeaderViewController.titleView + } deinit { os_log("%{public}s[%{public}ld], %{public}s: deinit", ((#file as NSString).lastPathComponent), #line, #function) @@ -144,8 +148,8 @@ extension ProfileViewController { navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance - navigationItem.titleView = UIView() - + navigationItem.titleView = titleView + let editingAndUpdatingPublisher = Publishers.CombineLatest( viewModel.isEditing.eraseToAnyPublisher(), viewModel.isUpdating.eraseToAnyPublisher() @@ -292,6 +296,23 @@ extension ProfileViewController { profileSegmentedViewController.pagingViewController.pagingDelegate = self // bind view model + Publishers.CombineLatest( + viewModel.name.eraseToAnyPublisher(), + viewModel.statusesCount.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] name, statusesCount in + guard let self = self else { return } + guard let title = name, let statusesCount = statusesCount, + let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { + self.titleView.isHidden = true + return + } + let subtitle = L10n.Scene.Profile.subtitle(formattedStatusCount) + self.titleView.update(title: title, subtitle: subtitle) + self.titleView.isHidden = false + } + .store(in: &disposeBag) viewModel.name .receive(on: DispatchQueue.main) .sink { [weak self] name in @@ -396,6 +417,7 @@ extension ProfileViewController { let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) animator.addAnimations { self.profileSegmentedViewController.view.alpha = isEditing ? 0.2 : 1.0 + self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 } animator.startAnimation() }) diff --git a/README.md b/README.md index 61f142bb8..23957fa16 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ arch -x86_64 pod install - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [TwitterTextEditor](https://github.com/twitter/TwitterTextEditor) +- [TwitterProfile](https://github.com/OfTheWolf/TwitterProfile) - [UITextView-Placeholder](https://github.com/devxoul/UITextView-Placeholder) ## License From aa23ce398f7ccf676e2295dbd3e17cbda9cd0b1f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 9 Apr 2021 20:04:12 +0800 Subject: [PATCH 217/400] fix: fix crash when unfollowing , fix cell reuse issue --- .../SearchRecommendAccountsCollectionViewCell.swift | 1 + Mastodon/Scene/Search/SearchViewController+Follow.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index c64db4981..85c543b40 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -75,6 +75,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { headerImageView.af.cancelImageRequest() avatarImageView.af.cancelImageRequest() visualEffectView.removeFromSuperview() + disposeBag.removeAll() } override init(frame: CGRect) { diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index ce7d13b8a..8b0acda0a 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -24,7 +24,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat guard let currentMastodonUser = viewModel.currentMastodonUser.value else { return } - let relationshipAction = relationShipActionSet(mastodonUser: clickedUser, currentMastodonUser: currentMastodonUser) + guard let relationshipAction = relationShipActionSet(mastodonUser: clickedUser, currentMastodonUser: currentMastodonUser).highPriorityAction(except: .editOptions) else { return } switch relationshipAction { case .none: break From a007b7a98005b2ec275bbb93ba4cc32d45681b7b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 9 Apr 2021 20:38:02 +0800 Subject: [PATCH 218/400] fix: color in Light Mode, fix search result disappear when push new page --- Mastodon/Generated/Assets.swift | 1 + .../searchResult.colorset/Contents.json | 38 +++++++++++++++++++ .../Scene/Search/SearchViewController.swift | 2 +- Mastodon/Scene/Search/SearchViewModel.swift | 28 +++++++------- 4 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index eb69bda10..b6cdde9c5 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,6 +44,7 @@ internal enum Asset { internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let searchResult = ColorAsset(name: "Colors/Background/searchResult") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json new file mode 100644 index 000000000..3338422aa --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xFE" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index b98a34b95..ee6b91903 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -77,7 +77,7 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + tableView.backgroundColor = Asset.Colors.Background.searchResult.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 46255fde1..2bd5b14df 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -106,27 +106,27 @@ final class SearchViewModel: NSObject { isSearching } .sink { [weak self] _, text, scope in + guard text.isEmpty else { return } guard let self = self else { return } guard let searchHistories = self.fetchSearchHistory() else { return } guard let dataSource = self.searchResultDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() - if text.isEmpty { - snapshot.appendSections([.mixed]) - - searchHistories.forEach { searchHistory in - let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default - let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default - if let mastodonUser = searchHistory.account, containsAccount { - let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) - snapshot.appendItems([item], toSection: .mixed) - } - if let tag = searchHistory.hashtag, containsHashTag { - let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) - snapshot.appendItems([item], toSection: .mixed) - } + snapshot.appendSections([.mixed]) + + searchHistories.forEach { searchHistory in + let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default + let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default + if let mastodonUser = searchHistory.account, containsAccount { + let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) + snapshot.appendItems([item], toSection: .mixed) + } + if let tag = searchHistory.hashtag, containsHashTag { + let item = SearchResultItem.hashtagObjectID(hashtagObjectID: tag.objectID) + snapshot.appendItems([item], toSection: .mixed) } } dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } .store(in: &disposeBag) From e784e123c9a3512eb3f1729dfa4aad517a7c4e11 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 12 Apr 2021 12:49:01 +0800 Subject: [PATCH 219/400] fix: searchbar wasn't correct display in ipad --- .../SearchViewController+Searching.swift | 2 +- .../Scene/Search/SearchViewController.swift | 34 +++++++++++++++---- .../SearchingTableViewCell.swift | 2 ++ ...veStatusBarStyleNavigationController.swift | 26 +++++++++++++- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 540fd20c3..3eb9793ad 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -20,7 +20,7 @@ extension SearchViewController { searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) view.addSubview(searchingTableView) searchingTableView.constrain([ - searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index ee6b91903..33caabd3a 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -17,6 +17,12 @@ final class SearchViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = SearchViewModel(context: context, coordinator: coordinator) + let statusBar: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.navigationBar.color + return view + }() + let searchBar: UISearchBar = { let searchBar = UISearchBar() searchBar.placeholder = L10n.Scene.Search.Searchbar.placeholder @@ -25,7 +31,6 @@ final class SearchViewController: UIViewController, NeedsDependency { let micImage = UIImage(systemName: "mic.fill") searchBar.setImage(micImage, for: .bookmark, state: .normal) searchBar.showsBookmarkButton = true - searchBar.showsScopeBar = false searchBar.scopeButtonTitles = [L10n.Scene.Search.Searching.Segment.all, L10n.Scene.Search.Searching.Segment.people, L10n.Scene.Search.Searching.Segment.hashtags] searchBar.barTintColor = Asset.Colors.Background.navigationBar.color return searchBar @@ -110,16 +115,15 @@ final class SearchViewController: UIViewController, NeedsDependency { extension SearchViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color let barAppearance = UINavigationBarAppearance() barAppearance.configureWithTransparentBackground() - barAppearance.backgroundColor = Asset.Colors.Background.navigationBar.color navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance - searchBar.delegate = self - navigationItem.titleView = searchBar + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color navigationItem.hidesBackButton = true + + setupSearchBar() setupScrollView() setupHashTagCollectionView() setupAccountsCollectionView() @@ -128,10 +132,28 @@ extension SearchViewController { setupSearchHeader() } + func setupSearchBar() { + searchBar.delegate = self + view.addSubview(searchBar) + searchBar.constrain([ + searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + view.addSubview(statusBar) + + statusBar.constrain([ + statusBar.topAnchor.constraint(equalTo: view.topAnchor), + statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3), + ]) + } + func setupScrollView() { view.addSubview(scrollView) scrollView.constrain([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 9fe0f1336..5a258d8a5 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -15,6 +15,8 @@ final class SearchingTableViewCell: UITableViewCell { let _imageView: UIImageView = { let imageView = UIImageView() imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true return imageView }() diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift index 8ba7c2257..5f4302712 100644 --- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -10,7 +10,31 @@ import UIKit // Make status bar style adptive for child view controller // SeeAlso: `modalPresentationCapturesStatusBarAppearance` final class AdaptiveStatusBarStyleNavigationController: UINavigationController { + var viewControllersHiddenNavigationBar: [UIViewController.Type] + override var childForStatusBarStyle: UIViewController? { - return visibleViewController + visibleViewController + } + + override init(rootViewController: UIViewController) { + self.viewControllersHiddenNavigationBar = [SearchViewController.self] + super.init(rootViewController: rootViewController) + self.delegate = self + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension AdaptiveStatusBarStyleNavigationController: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + let isContain = self.viewControllersHiddenNavigationBar.contains { type(of: viewController) == $0 } + if isContain { + self.setNavigationBarHidden(true, animated: animated) + } else { + self.setNavigationBarHidden(false, animated: animated) + } } } From 7bf6328252bf72300aa0d1fb8d48142b1099725c Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 13 Apr 2021 13:06:35 +0800 Subject: [PATCH 220/400] chore: code format and add layer.cornerCurve = .continuous --- .../SearchRecommendAccountsCollectionViewCell.swift | 3 +++ .../SearchRecommendTagsCollectionViewCell.swift | 1 + .../APIService/CoreData/APIService+CoreData+Tag.swift | 10 +++++----- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 85c543b40..f45124671 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -33,6 +33,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill imageView.layer.cornerRadius = 10 + imageView.layer.cornerCurve = .continuous imageView.clipsToBounds = true imageView.layer.borderWidth = 2 imageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor @@ -65,6 +66,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) button.layer.cornerRadius = 12 + button.layer.cornerCurve = .continuous button.layer.borderWidth = 2 button.layer.borderColor = UIColor.white.cgColor return button @@ -99,6 +101,7 @@ extension SearchRecommendAccountsCollectionViewCell { private func configure() { headerImageView.backgroundColor = Asset.Colors.brandBlue.color layer.cornerRadius = 10 + layer.cornerCurve = .continuous clipsToBounds = false applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) contentView.addSubview(headerImageView) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 813c8a34f..d00cb0504 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -68,6 +68,7 @@ extension SearchRecommendTagsCollectionViewCell { private func configure() { backgroundColor = Asset.Colors.brandBlue.color layer.cornerRadius = 10 + layer.cornerCurve = .continuous clipsToBounds = false layer.borderWidth = 2 layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift index 3f931ddea..9b4319572 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Tag.swift @@ -28,7 +28,7 @@ extension APIService.CoreData { return nil } }() - + if let oldTag = oldTag { APIService.CoreData.merge(tag: oldTag, entity: entity, into: managedObjectContext) return (oldTag, false) @@ -40,8 +40,8 @@ extension APIService.CoreData { return (tagInCoreData, true) } } - - static func merge(tag:Tag,entity:Mastodon.Entity.Tag,into managedObjectContext: NSManagedObjectContext) { + + static func merge(tag: Tag, entity: Mastodon.Entity.Tag, into managedObjectContext: NSManagedObjectContext) { tag.update(url: tag.url) guard let tagHistories = tag.histories else { return } guard let entityHistories = entity.history?.prefix(2) else { return } @@ -49,7 +49,7 @@ extension APIService.CoreData { if entityHistoriesCount == 0 { return } - for n in 0.. Date: Thu, 8 Apr 2021 19:47:31 +0800 Subject: [PATCH 221/400] feature: settings --- .../CoreData.xcdatamodel/contents | 49 +- CoreDataStack/Entity/Setting.swift | 84 ++++ CoreDataStack/Entity/Subscription.swift | 101 ++++ .../xcshareddata/swiftpm/Package.resolved | 142 ------ Mastodon/Extension/UIButton.swift | 20 + Mastodon/Generated/Strings.swift | 56 +++ Mastodon/Resources/Assets.xcassets/.DS_Store | Bin 0 -> 6148 bytes .../Label/highlight.colorset/Contents.json | 6 +- .../battleshipGrey.colorset/Contents.json | 20 + .../Assets.xcassets/Settings/Contents.json | 9 + .../Contents.json | 12 + .../iPhone 11 Pro _ X - 1.pdf | Bin 0 -> 515682 bytes .../appearance.dark.imageset/Contents.json | 12 + .../iPhone 11 Pro _ X - 1 (2).pdf | Bin 0 -> 473595 bytes .../appearance.light.imageset/Contents.json | 12 + .../iPhone 11 Pro _ X - 1 (1).pdf | Bin 0 -> 463573 bytes .../Assets.xcassets/Welcome/.DS_Store | Bin 0 -> 6148 bytes .../Resources/en.lproj/Localizable.strings | 23 +- ...meTimelineViewController+DebugAction.swift | 3 + .../Settings/SettingsViewController.swift | 430 ++++++++++++++++++ .../Scene/Settings/SettingsViewModel.swift | 295 ++++++++++++ .../SettingsAppearanceTableViewCell.swift | 207 +++++++++ .../View/Cell/SettingsLinkTableViewCell.swift | 31 ++ .../Cell/SettingsToggleTableViewCell.swift | 70 +++ .../Settings/View/SettingsSectionHeader.swift | 54 +++ .../APIService/APIService+Notifications.swift | 66 +++ .../APIService+CoreData+Notification.swift | 129 ++++++ Mastodon/Supporting Files/SceneDelegate.swift | 3 + .../MastodonSDK/API/Mastodon+API+Push.swift | 135 ++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + .../Entity/Mastodon+Entity+Subscription.swift | 42 ++ Podfile.lock | 2 +- SubscriptionAlerts.swift | 131 ++++++ 33 files changed, 1989 insertions(+), 156 deletions(-) create mode 100644 CoreDataStack/Entity/Setting.swift create mode 100644 CoreDataStack/Entity/Subscription.swift delete mode 100644 Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Mastodon/Resources/Assets.xcassets/.DS_Store create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.dark.imageset/iPhone 11 Pro _ X - 1 (2).pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store create mode 100644 Mastodon/Scene/Settings/SettingsViewController.swift create mode 100644 Mastodon/Scene/Settings/SettingsViewModel.swift create mode 100644 Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift create mode 100644 Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift create mode 100644 Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift create mode 100644 Mastodon/Scene/Settings/View/SettingsSectionHeader.swift create mode 100644 Mastodon/Service/APIService/APIService+Notifications.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift create mode 100644 SubscriptionAlerts.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5ed4021a7..fe0c43529 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -154,6 +154,15 @@ + + + + + + + + + @@ -192,6 +201,26 @@ + + + + + + + + + + + + + + + + + + + + @@ -207,14 +236,16 @@ - - - - - - - + + + + + + + - + + + - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift new file mode 100644 index 000000000..a4907f0ab --- /dev/null +++ b/CoreDataStack/Entity/Setting.swift @@ -0,0 +1,84 @@ +// +// Setting.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// + +import CoreData +import Foundation + +@objc(Setting) +public final class Setting: NSManagedObject { + @NSManaged public var appearance: String? + @NSManaged public var triggerBy: String? + @NSManaged public var domain: String? + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // relationships + @NSManaged public var subscription: Set? +} + +public extension Setting { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Setting { + let setting: Setting = context.insertObject() + setting.appearance = property.appearance + setting.triggerBy = property.triggerBy + setting.domain = property.domain + return setting + } + + func update(appearance: String?) { + guard appearance != self.appearance else { return } + self.appearance = appearance + didUpdate(at: Date()) + } + + func update(triggerBy: String?) { + guard triggerBy != self.triggerBy else { return } + self.triggerBy = triggerBy + didUpdate(at: Date()) + } +} + +public extension Setting { + struct Property { + public let appearance: String + public let triggerBy: String + public let domain: String + + public init(appearance: String, triggerBy: String, domain: String) { + self.appearance = appearance + self.triggerBy = triggerBy + self.domain = domain + } + } +} + +extension Setting: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Setting.createdAt, ascending: false)] + } +} + +extension Setting { + public static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Setting.domain), domain) + } + +} diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift new file mode 100644 index 000000000..5d65129e2 --- /dev/null +++ b/CoreDataStack/Entity/Subscription.swift @@ -0,0 +1,101 @@ +// +// SettingNotification+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +@objc(Subscription) +public final class Subscription: NSManagedObject { + @NSManaged public var id: String + @NSManaged public var endpoint: String + @NSManaged public var serverKey: String + + /// four types: + /// - anyone + /// - a follower + /// - anyone I follow + /// - no one + @NSManaged public var type: String + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var alert: SubscriptionAlerts? + // MARK: holder + @NSManaged public var setting: Setting? +} + +public extension Subscription { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> Subscription { + let setting: Subscription = context.insertObject() + setting.id = property.id + setting.endpoint = property.endpoint + setting.serverKey = property.serverKey + + return setting + } +} + +public extension Subscription { + struct Property { + public let endpoint: String + public let id: String + public let serverKey: String + public let type: String + + public init(endpoint: String, id: String, serverKey: String, type: String) { + self.endpoint = endpoint + self.id = id + self.serverKey = serverKey + self.type = type + } + } + + func updateIfNeed(property: Property) { + if self.endpoint != property.endpoint { + self.endpoint = property.endpoint + } + if self.id != property.id { + self.id = property.id + } + if self.serverKey != property.serverKey { + self.serverKey = property.serverKey + } + if self.type != property.type { + self.type = property.type + } + } +} + +extension Subscription: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \Subscription.createdAt, ascending: false)] + } +} + +extension Subscription { + + public static func predicate(id: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.id), id) + } + +} diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 3bd82fce8..000000000 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,142 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "ActiveLabel", - "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", - "state": { - "branch": null, - "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a", - "version": "4.0.0" - } - }, - { - "package": "Alamofire", - "repositoryURL": "https://github.com/Alamofire/Alamofire.git", - "state": { - "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" - } - }, - { - "package": "AlamofireImage", - "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", - "state": { - "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" - } - }, - { - "package": "AlamofireNetworkActivityIndicator", - "repositoryURL": "https://github.com/Alamofire/AlamofireNetworkActivityIndicator", - "state": { - "branch": null, - "revision": "392bed083e8d193aca16bfa684ee24e4bcff0510", - "version": "3.1.0" - } - }, - { - "package": "CommonOSLog", - "repositoryURL": "https://github.com/MainasuK/CommonOSLog", - "state": { - "branch": null, - "revision": "c121624a30698e9886efe38aebb36ff51c01b6c2", - "version": "0.1.1" - } - }, - { - "package": "Kingfisher", - "repositoryURL": "https://github.com/onevcat/Kingfisher.git", - "state": { - "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" - } - }, - { - "package": "Pageboy", - "repositoryURL": "https://github.com/uias/Pageboy", - "state": { - "branch": null, - "revision": "34ecb6e7c4e0e07494960ab2f7cc9a02293915a6", - "version": "3.6.2" - } - }, - { - "package": "swift-nio", - "repositoryURL": "https://github.com/apple/swift-nio.git", - "state": { - "branch": null, - "revision": "8da5c5a4e6c5084c296b9f39dc54f00be146e0fa", - "version": "1.14.2" - } - }, - { - "package": "swift-nio-zlib-support", - "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", - "state": { - "branch": null, - "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", - "version": "1.0.0" - } - }, - { - "package": "SwiftyJSON", - "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", - "state": { - "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" - } - }, - { - "package": "Tabman", - "repositoryURL": "https://github.com/uias/Tabman", - "state": { - "branch": null, - "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", - "version": "2.11.0" - } - }, - { - "package": "ThirdPartyMailer", - "repositoryURL": "https://github.com/vtourraine/ThirdPartyMailer.git", - "state": { - "branch": null, - "revision": "923c60ee7588da47db8cfc4e0f5b96e5e605ef84", - "version": "1.7.1" - } - }, - { - "package": "TOCropViewController", - "repositoryURL": "https://github.com/TimOliver/TOCropViewController.git", - "state": { - "branch": null, - "revision": "dad97167bf1be16aeecd109130900995dd01c515", - "version": "2.6.0" - } - }, - { - "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", - "state": { - "branch": "feature/input-view", - "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5", - "version": null - } - }, - { - "package": "UITextView+Placeholder", - "repositoryURL": "https://github.com/MainasuK/UITextView-Placeholder", - "state": { - "branch": null, - "revision": "20f513ded04a040cdf5467f0891849b1763ede3b", - "version": "1.4.1" - } - } - ] - }, - "version": 1 -} diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index 916ad222d..d4334baad 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/Mastodon/Extension/UIButton.swift @@ -43,3 +43,23 @@ extension UIButton { } } +extension UIButton { + // https://stackoverflow.com/questions/14523348/how-to-change-the-background-color-of-a-uibutton-while-its-highlighted + private func image(withColor color: UIColor) -> UIImage? { + let rect = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) + UIGraphicsBeginImageContext(rect.size) + let context = UIGraphicsGetCurrentContext() + + context?.setFillColor(color.cgColor) + context?.fill(rect) + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return image + } + + func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { + self.setBackgroundImage(image(withColor: color), for: state) + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 14b993881..dac01a4b3 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -581,6 +581,62 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Settings { + /// Settings + internal static let title = L10n.tr("Localizable", "Scene.Settings.Title") + internal enum Section { + internal enum Appearance { + /// Automatic + internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") + /// Always Dark + internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") + /// Always Light + internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") + /// Appearance + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") + } + internal enum BoringZone { + /// Privacy Policy + internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy") + /// Terms of Service + internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms") + /// The Boring zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title") + } + internal enum Notifications { + /// Boosts my post + internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") + /// Favorites my post + internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") + /// Follows me + internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") + /// Mentions me + internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") + /// Notifications + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") + internal enum Trigger { + /// anyone + internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") + /// anyone I follow + internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") + /// a follower + internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") + /// no one + internal static let noOne = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.NoOne") + /// Notify me when + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") + } + } + internal enum SpicyZone { + /// Clear Media Cache + internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear") + /// Sign Out + internal static let signOut = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.SignOut") + /// The spicy zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title") + } + } + } internal enum Welcome { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") diff --git a/Mastodon/Resources/Assets.xcassets/.DS_Store b/Mastodon/Resources/Assets.xcassets/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..4fb9bcc5505725330d7145831e719cf5a4e88381 GIT binary patch literal 6148 zcmeHKK~BRk5Zr|xB5}#FN8dT+2cZft$Olj~S!c&;>E-0Zna6ky5 z-N>G`y}PzYitQBs5!=iJjzy{cH63*3G^4cxb{L? zxszvnqxIkR-F~#-6PvDYn)R+<)Bo^xTwa#1S97tte)BE6{e9JbnR7IKsKG!m5DWwZ z!N89(fIFK~To^_j39`Z_K_ literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json index 2e1ce5f3a..d853a71aa 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/highlight.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.851", - "green" : "0.565", - "red" : "0.169" + "blue" : "217", + "green" : "144", + "red" : "43" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json new file mode 100644 index 000000000..37df8107f --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/battleshipGrey.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0x80", + "green" : "0x78", + "red" : "0x78" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json new file mode 100644 index 000000000..75da4a571 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.automatic.imageset/iPhone 11 Pro _ X - 1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..868d8d8b9b07a2e1d50de1be7f0330b9513a06ee GIT binary patch literal 515682 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
COD4e^}OmDCcdTe{_2-eZCj$6tr?zU_^a(t z!ldK-(W#FGl1?^uYWQc{GQxDtSZ=MHMsxY6F;{f|(rs=e{cSyWxx zqvc=FF9{UOf?M%Q`ISo z5&Sks_ypN(^tm*ci^-T`X(iDNd<3(gh_VHiAprWXljKiwsy5BfQy9qUkcXHPp23u*`jejLtb61a1f1$DPoC{6NN;l%ToKR1JIvmZQvLJVp4aj1Pjb}zE0 z&v7Tl%*iD3|;5(7A>~9x*1;_GG`A0?8DQ!Fs0rAn^jQ(eAiZ3hvjDJ?JO#ndp?J{ zi1$5~SU{HlA(u3QarnjFXExlm{*@Kyi>(K(dO0)nO{HWK-=BXe9*=8!`?5fRWw;l} zXSjZL$uEiTZSb>W1Ivl4`djXM?~MlT$K2VKVGdBR9u>;0(=7h>p$MI;;VtHm8vd;F zGe>+dsmJEcwU39x#QeLl90CK&S~u$$>yGAvG$O`B$dR;QC;zoS%(DH$mX57jDVRcK zG_Wz&(|_}Y&B(A-qQ5mb1_W_SxUM;atY2?_|_UxuB~ng=8=q za#Id90KQ>CBJ{j~l^a!#hciMAVVChhJpw9@GluVY0Xknkh}=++95r#L0$6;U0+Ir} zJdiQ@>LJHv2^#_J9H}=(`UEOJrrvEnmKkzwH&}dOsZ^@#%o2o$dp}C2 z5=POs10NQwlpjPse`fRe&8&El6}ML2i|})F=v}LiazfObynsI=^^GVAo*u{NO8s@J zaD&@v(kVT}7wkGvbnG{0dv}_Ynhl$K{>H20P{5zLWW41g4Ag_osCq%dNbdC$?*Z#Bqn9hR=gF7Qn>W9$;vR+5?NqTE{&G3Rd zExlW}1QXuJmG+MYqn2X_+Jxj(V{4;xK76Vvsv6N5^mir!VnBL^P1%99@o)vVEFXVo@N zRHA#UC8wzGX^jiig0@xjX8%qNDaTXhxK7cBNZ$_MUe3hG+#iXv4H1-s;N`<3)EaJpf5WH7PBYC?AvWg55-gWl#? z(GEN3=Irpj(eJ7AxtX>;|B>zZgKZmO%v2uZ@bDb_Lu3Sg{c2uY(BLW#+QBDHxz8C# z{9=S{!Yy6Rx$-*j4{M-=$14;{GnY8D5KQWoh)Cy?cns9t>H+_e8#}8@r^ifLljMNZ z-$03LjA=i_qoF#2TlFmXJ+0frI?9{P&qpi69v)_r?VA{$U#1}rVVZ0zL&ANo^<9r7 z$yuO(eK{$ybI;m%(Q@zcUB8lb5h%u5QRHY3w(fu{6F*qL*xd$FAt=tU*09Tamq>E(Jsm#+1%?!3e>MI8y2qN}y-HQA4i2`Npi*(IeSj+;eBe$!Ivy*02*6}JmD zYYs7Y)c3Cp`3EP9bY~0~%sjW%wNvY}Lp|nLYu4iz==gPH=cKo4{0PEP*0d5Z)DB8IuBI*nH2Zz)-zz3)C%s1jRSus+q zx&tUWP|~TYu~2a}GXbFT>Of+r!d9d~npem9G4BQKj(!2#N$=*n!l|K~v<{5r+Qc(9YQ!J&l_h~nCFw?01@R&bkmH4EQkeaqgJjhP0|xf5QCj2`wX21}zyG|P!1 zGGUCpiun=ksOGpj3$H{GT1Ub7v5KmtK2U`FZ(K`Hshm9E!fn1(WpM&D-|XS)WI% zd6L%g`!IgArYO(6u6jTHzRl8E$d%7ZSGgvrye86?K7m<`#ddGr)tcMS*Rt;H?yo}Q z@v(;y&uS5;mP;c)&>-l?IY#~2CEa4yuH$&ZLAR4wfAUj~mgXaN(fG-SY zp3x`~Z17{53SP6o{1b#nzu=J+4`}i+zg8 zF}9K>W+$7-zcKxf-AvApMZz@`Y*%nw9y6A>$vZ|}3(m1Tx`ryaWZ#@0h?#rqla=e7 z1nflA-fULQ2?!j73$9HGO&IPw5KMzkqt9H?ET=?+nZrX;M&?|gY$$9aRgoi@c$6Fx zXe5l1Eu}|+G#zAgN<_gpU#MnNj_{==k4W+hJ_$Vsk|3>v6h7DUf-J2xk{^s&4;WI) zV^>UQ32kY;Q;Y+l(o8H4}%A0E$+!q_(xv~9_P(uL&!lj> z<``U`I4;`Z&MMVWo_L~WBc{Tiiq*OH0i21EG4Hn6^5^H3#}X1gHXnKT1>=g;T# z^eetekyy}DUE_+^VZT5l3Nibnr0#=q_`>l$?ws)btblz zdZtO251qmw^)K$k(^0+_uwesevNYKo@|1rb+F4E%KoHhv9|;BXY;iWp7uq9!esx`7 zg_)z=BSjTass=~2WzyL#K9DzzZ9doYcCd#?o>q>oo~j+YP7?o64?zn01@-yIE2_R# zK(7eUt`8BrTPW}HQ*gjnKa?axQ+suQ<42W^+DO=mS+^p;P(U>lb`e=InKZQ`o{|Ag z*(f4zTn51%I&9Kf+T8te*F_rr(Rw~|TPP`DF;QE5AfLJMtJVVJPQ`(Vk_0NAObe(d zsHXqX!?2Ix;j}7)%|}(SE5w%**-<6!jY{flOoft=RMeWifl#l!7qCQBtMjcn;lw=j zXLP;JMj*=FNi7;_05Iii`r&dPO&!4oC;P@GO z2RImp8`L+jKVnS0DQX95&UxnT+T?!I-Kx=TNnY_yj+C0(ILGPGzGyGs6*t-eima{q zTd~5cr=S}SqVCAMn(!6xjnJ|owEpkGjvnpgl`+t!usE={8e(vKdroyA&_;A)ns7d$ zUE{}yuF+hN8hjuF#>$8qp~c;yd^j@|e?A#vDk@Sj#Ibk=E)WpPfuIo@TfMHyEL}_h zBWAD(O^jwe3x^}oIz|MX4!!bZ((bbmJFdb6K=BVIoHbR7$kR z1R?=nSE)l|pei8{!ldM{4%^;NxFGiJIhHHz5qG4Tqgg9WEq}U_g3hUK6-FuZpHA>e$aj9JIj^LA1{gKJo9h= zz`K{1aTeI=1+u~_)t<7hzTq179Qu2_i9-?RT}h07Gr!ndfrbD%+bKx=$6Jxzr?8g1tj7JUl71aD5a(UIXsUP_mDWXMWAhm zfc8L`5@-i!pxC280@YpDf7YP{X8AT+s zi8F&8?u@F^YI65%*H%LwsBsn%ZZ^S^Kq9REnMWvS0pjnShD;|Gb8{7eiO9z(dH?0KhQ>Q4QV=>; zoyM5jxVA|m8ybNBikGsEykuH|D6r5hHAAZnL0I|5;K(#rrKIk4XHw=)}ihZGnP1rfldetr|+J;SD_5lH(V?kg-yf<&N?X_OeU2#}aUZ$WQr zl~!tFe+?oI^JHr%gRV^`L|dx<8!a?S`|is8WEujhzlDio zu|5=J%RKXnwt`>)b=syi;Zh~0|UGW zF&>(z~pQh%&3dJiUV50FwPky9i6i zp+Xk9pmCA-w;%7Tf{!B}Y+}sL`VkT7`^4_KG?+hDYx01Ls5A&g(501d*wQrAx6z6u z?m5N}_#s_eig+xoT%GQsyK9BqAzi+#b&)ZG0%UyX&;JzxrcwVhUP>=$*Z&e3{0T#-$*uQuXsgr+m;^loQ!v?G6cP68D{^ zOt-|HM0I$2NOHV@`SMi>*6l}%38TwQB)1bJ0&MEv;7jocAhbfPLRwXN)U>g8q^sgG zs!&q|YE3u{06OfOiFS}?Db+yYrB?b*swVnnr5f-Gz$PY>CY}kQLL44nD684Iit*8J zvrBTifG*5A;+ZQ*kT+ymN`Q|AdPJR_0%#jVm4GWXg?$NDNr2UWu%JJ+FVj>Y1Db%f zbJPp4fW2TLhkqidVBuLd#NjwYC`Fo=8IcZsol8goVIQT|E&}BpAtHTmw|nkBOx{Ur zibz{78BQjW3~=!of@9)%Fs+iVeMz$<9)TwBQyG_>M}I0_)^p+TitZqW=$2GbO03U26- zr>L}VKtU~?#NR}?Ag@_)ellLB&SegYg>937`ZpM?XY40c=pAPaps>rNb1nI9X$gHS zo=MQYDlS7QQu_|ZV(U{`?K%RF|GF&dYZdwlm&eEp%>W%ivr~>N1~pEQ4UI5vdMaxy zGlg6oCBrZ}NtM}E1fK*hhs)5&8WW*t6XX#MGZ5RZB<0x_oiHdsoM43JJW(B)H`O>c z;>#fpw?R8KBw2%m0VUy$6}EGnFQBbVIQu1}q!fNAGLK0I<*XkaeEt#esYAP{F1$X7 zk=SDi@*l5J=9>sNO0{gYTd)>GwZlH~HF8o#Q&X^lNflahuVPq%L4it>lZwqmA~XRYhdngL(Yk^qN>KlIOTXt;#;b7%wMB(*xdTPr znTX5PBcW$+IGn0VPKT9e1LQV^vzKH5BU}v9*9&mpvY2lOi7$V~h$DL9^s zk1QL)_uUZTbgfv-ZZnY|@fZaf5f*L*298yL!T)d{{fa3l7J&A%{>RdCpz$RB!BGf^lP5lE<)GhlOu$Cq(rRpW${(0B$ zHqrtoMe&!&nA)Tmf0!5H%$~-kfRP9x7y9|ji_pU&%3c`{?HY=?f#qqy^m)imzdAzf zQ4{n#q{S?prxA!c=;4LSvz+CFE&sQMRv7fMAr7i}b1>2bGDBfR#t(@l>?}0%XP4Ng zXFik_zJldQE|-g1@&-gM3!Dg)(;0%a=q4qp3OMmWj)4t&2oZ4zIaCH8j-2Um{5=ef zO09O*G`}|VeSjf=U*7}l0oZ?j1FCUTX24gbhhxAZ^-2M_lZ^z&tL;IDQBIJ9eX&2! zEiD=B5tR$ArYIS3XQ_%Fi?BFel_Xa~3jbwIC}mhmqgY1zm-?7-&`-zm$7A!xYv6yjH)9!*g; zM-Wd(tA@g>@CY}9epU>~B%(je>296t)%TeVAX{zKOK2m01f+#dU@evU2fsiYXgq&m zD!!@h#6z<@LBca~>S^FyPzWz~2zCTOU+BLJdg?bfP?x-?qw$NCBjV<#m>`tPEQ)L;s;fuRC`&qJI@35|Ow z%T)Nciq3h}LP) z=?^TPqzBuML?uL8$iG8&CosBOw);BDf4efuqF51qIt)^ofp5!IPpRQ3vlqZXZ~bBO zSe_}JxsezM_e({<7=-%f+%-nnE|x!efQ8nHNcm>v2ANHd%mbwz#aamj{4!WR^l{(T z1wH=+N{EVSJV1L`jhVSvg}@K|0i-`TB|x!-4O$|)XwesO#ow)Owm6x`5}w6^?0%fv zjX}{p{Fo8UIPgn>%oh1l{HA1B;K-s(z_=GCuWkf64nEmO~tmD5Sh1agnf6reczP&6DM z!lLxZEJ#2VkRgP9{mm1Q%&C7@n=mzTM^3sSgV4sJBkHb1eVb^M8HOTO(~d6K0JgNB z(=WV$zJipHuh<-uE1~HRMt*TK8|ae8Vcz12Y&T)x4LG159cUA{EZeI`fnzT5xN~*l z3wy(pF|Phen_Vg(g-P!YZlqKuqA$(UdYbyLfhNRGjCg)JA(*7fC&SPLhCk#%OSj4v}K&>-T5Rm zDg4!upr`eHFE(ERnxFy-1Y!_OB=j-+0jbHOA$X0)MikUztb^ll^Tu1Gq=ZcQN9o8ww6llj#KO!nrdK#m^#E5;*Qd2HNPc6gHG zEssh`Yx8*+cz*Y^fl0yJ5J7`xV1iuEIVZ2Q{|gt-_iMjL6C8P_U}s9Np|k+Z7~Gt> zh;y}|82rR_87$Gk<034|TVJ~8>9-oldrS`p?3I_DaZ7|_b~Xcj1w3n-0lWPyIY(D2 z#|wxb#ANE*su1%Jrt4v8zl&ZIu#ZK-j2E1`}Z7!t6MLr2}=65#V7%mB4W z5N7!4LTkl1UfXm7_}G#$so1hJfe4FPZuZk9%p0lf4vB-*2JMwgBH|;dikGbTfrrz0 z#&WPq5hwy$hWBwmAFwl9L9+**mqidkmRpL0A>j9Zng^&`7Qi0xMWKU*Z5mJ4n6+FQ zZ^XFzecKpCS}YId>J$z4r$UWl2`rkv(p5yWFh>qe)-aJpyNdIw1b}r;!p$BsjTCCEa^MR-3vcA8_2cT-s7f* z{)AP1P(}5ru?0P)Y7_zJ4Z4fnNf#>wPR4o7**I1KaWEth4?2GHWV~;cG!YUGN{Xt# z`AgNrw2Dp@Xe2k)Gpvq)qmyX~1vDWLww;HsLMM)4_E1L~xxo-R_oN4?U&jSO+XNu* z;G`!9`3RL(1tmbZIBZ;1Jq|2%J+lesA2+ukDFs0+nF23E`U%s{%{_J>Y5Q6NqR6#rqe>7N;cU zb`ju9OwtCJp<44jU^9upHY2QHn0L03Ra$uyCJB3qMKwYeXd<0cI`&b}mGzYWggJyA zz|gdE{GG-C{0_FQf)WAF7Rg>Y zVE);ay`p;F@b-O#%<3$p=n54Ma(o$Z1mJ@Sg>a}CQi+ERf4~$MHd`Pq|cd8Fs=egMnBEm5&@u+nHRO$I#ZOG!Vh`<}DyJCk;{BItzIuz_H?V=d_p3V2G>Yg84$LJzPToefyZ$m)3B7%v6cNT$QxK!U&-{o)MCd45I_7tMRlxlgMTT;weWR7D~9Ru1$l?ZTX3)5NesSy{XsvrV^oS4#8KQq1&M68Q zKB4So*n6B07d>kV$D9FKJXlDGtt8?S63i>%TeHkd94vWaO#T+egXJxcf}!t5M1KPL^b1r{;gHB^y z_KJ5Y(4FveSClxHWpW`X2ILjIL{(;2G~N_z?c z+BK|NMn0)9gW#WqsJ+L~n4W22T`2y8FBkxRY_lelW`a{un8x>yehz@#TrTAdZ)ey3 zuFu2oPrzVVoHv_NKd+E0FNIW!!Yb4>)4qZxBO>deUk}caFji+q?Wb?-8x;Q9QQqw; zoxMLREgt6T_U`cd3jvD+hSoRpc_BH{4S-M{n#h+%`BqfCrthEhPT_7rgINNyOhR_M z-of@0#T9_KBPj>I0(Yx0QyJm=Boy7pgVoNKY+LZ-HJC&K}!Bf3U)pEWI%&mB6|zzUjnA~ zxOR>+q@C7prabY$UxJk@2-#)_MGL(Qm{DZ;T;x4k&l%7rCsFV{8Bo6_YjecN(JIuG zS^C$X4oc6hCMX#ManTVzQ?es;Pt1_?a&X)b_3tCTix;`v_+K}EGu6owAP5Y^G}Fu= ze^$J%{potZ+{RSfI`O)?%;~P374;*B!X8$?SjwOE)=h@^kCUjoyhy*%d!TAAE}?As zwFRJ2i11Vr6JM2d*PB#ZZ;lwfyW*6eHIbjY&;LKTdhd8R*RE}NW0WDvj2h8<@3!7Y z?_IQxz2N~ zb*$q!*1EohQv=GfoAw=88%1`Gz%YkOg@DO~9f@SJUs}^B5l|)*w1T-P0v;_PfO*Zg z9F+IJ1#7<+NMY&}KHv5*cYaSi3jrt2_w6d4xvJs%9+!*9^FwT6uRFo9-uxtUI$3v- zvlGv*Y&Q}>7{S>>DqCYD9UmeT$G;QJ#@l#gR6?KO*p#tm{YhIGQD>iFHy=8<>24*v73SWx(Am{@VlK)ZvVLoqZ#jh@3e+as&RU(cc`sJ>zV58 z);XQ^I$g9x9st+d<+|F%>E-(FA56}`uiOKtGi?=hkJqYlZHGe-gmcf$+3H?ceH-gv zvs5}e2{r!P`NU;jm1i-zm4!D#sipVeRr!IN&jSEPmt01Be8!v2fplbYt=kYQ+TJ_6 zAMU&N>KH%_JzqRs1xi2vyEt2XK@H5)1wJ>hXrsQdcnCjYgoiKSMrke3&~KQuoU z0L%cJAfH?1Fo;Mu(o{VH`Ht!PBp4FHZ|DHPHCUxhG?KjrKmSfbKRgP*7{Wyx#5Ss8nNfBn`_XS4c1t>_yY0_ zZ>PCe8(&}-dS&#vP4;2-o9c-%%drRHr=Rw-j_}vwtJ-hI(8-1jw)CTT;he%J5%Y`} zEhI8OJ1!fFBArx&%|b7aV=B_(`BeP$8~yjnj=L`4SUObPah8p!E^*YhX!J^&v>jn z_XPd7h1R& zy}jJ&NQ<1OP^+tb^utN*m+8gFj^kfVGJL7@rbhOPmEZv$Fg(ZSB|@VJw~M1Zf38CYCzFLi}VWM_kZp zG|Qw7&;30llYanMFuP6A9Jd1jhjy3^FHD*NtsOjCJa*s<`i7zL05~0NO8)fqrw2 ztI&nEd2)(hS4_ycfW^DMybmuUPvvf{|7dWG{Ku)cZQ?OMzApowlN&fPz=#948v>M+ zW12_!M5?0k+`wgkR&@o);f6{^!FUE&6wo2e02!Md78q@1`lllX1VKeduYK4AHV(WU z2V^yM;8Ov8Ts#flIzR!4^d-QA0Q+2d?CV5tpe6vjAc!J1VE+>|T9$4OiG4x@?7iKr z7EE^lSbzd;0`&aq_a-40pA6pt!#lWAi-$Yv&1B^WIbSR#csvlKM4DK6r&dt&RlKVb zsDW$iD}!PxPu$xsUmSJ$jU0EWajtXf4I6|`j&(@>8IL@_7~@WLJ$lB^v0NW<7IUe> zr@|mOHJ;-L23u==MdQHoyR+rU6Y8U$Bi&2WgO!S)C5?5p-hL?4(}&hcyA=}dU>R>1oi!7m$6Lx34B!xpF-;QNA1sT|t|c;N?%_%W1!K=#6$ z2cz+e7Ae|C0XbQ71-M+yfx=%D%{~CkG4wr9{UA znG!fSYQjla2*7hn`yh}GAba#4nAjnaJ^vsV;Z%oKTxpzDQHDvj=-ugV1!k0;p*96z zVU%7T0-!rcqeahfqY~bSHNUUJBD(h$&-vrfY~FKQW1_JaxHWeFTI!3U z`SlW=RP9iz#0odw>`-up*g}p@$|_cwDc#rS1=w;MCnf=u2Hnw5IT>#BcIae>{ll>8%wFzhZ@@~en3qE z8RrGC$A)%>#B72x`Dd&yQXj-3-56Fi*kx=xmJJoY-Y>wAs(ePuCz@Tq;E_;4Ad1eh z56w%QDmveC3!rH_qBc54>+=`8ajCF)tZNA7Tm#h~yp zJAp2+X(088{+>BnCLfG}fe#y;o%3b}P_D$Hz_blW)2-Ru5w?~`vie~2FVOhqZc+fD zvYVA_;>UwfGyJ;3y2ngP(fSbO{P1YWrN^D4Y0iSPK|}mEe~bk}fhig2d0+&y6F8i} zO9j%bKvNFvRlq9*a<+}}f_`-b}ScFz2XK>bs| z-mNtB8o0?m-Adt6mOCH%0X!e@t|G8mx&EjVK5P*Ad-o!#@nR+NH}%oGLjWTGhzoGq zUmTS?7?#pg8-yB+o!y9B{up_r8)1j`rQGW&mmV$pF?>E1sYPv2zu=U6R9s#`T48r~ zf<7^U;4L*~nNLm_0PFgZ^kTc?_b)J+-codW{ zMVijzQ6Te3-lDEwyjv`Oc$5QZ)k9gNO`GU;S{3$+ibxNO+8u~48L&G}7yyfXcbjGq zXk}aa2lYvw;k#PAWyvh_DcGX|J_2yr37S@L;9YtZPl1W#uN0?Of$zJr71yC)e+bxo z%>}gC-zG}efy;^3@{=Ea;2#j!=^f{}5qfgqN@B`Bku;})Rvj-eBN|h-*7_HWMQ0P8 zrZU-58jsZJgwN|Pm(<2G+_g)2N8hpe9la{!9nE;PfYvwF8faRw7i!|!*g)&2nh4$c zGkVE39(lq#yS~m|c%)uVT7k{F#Dfix=Wd>2-8s>jEOnq)t1jW+!QL3pthqnqfA~gx zmU^>A{|1Yi6-eSXOzLxVEHnyu(wq%z3X(Wz53{TR*K5khXn3mB@w+vYL$SsFh^InV z=$_BH85cfOd45K(22?o!KSVWp_Fq77%vEd`lpLt9&P8;MU$GN0LSY*>+ALRm`%5T2|v8RO=k2I9Y2 zTt@kab9bd~MGRfVx(3~G8K#d1ed$roI)bQu6=r4F;wg1MvY(Np@Pw+-`S zoI~ok64YtQP!-}J+5neJdluUaF@F}gG(Tb z3@-mCvGz)Htx;&(r{mBK$4S`BdtXEzssv|uRtQ&egHr)XHgBLw5IK_|25cmHi3Cu8)}5l07g zF+Ixg&{lURZ_$jJN`z*657z*OOt%ujb?RwgHDTSGa3A%gx`iM`Ex8Dx)BM}ioVa*O6So3Obzu>W809@ddM0< z@Sm?q?LJ-nZD81WW6odr`45$_KotMSBCe_8N=UOwxtQ#8z2tL_V~Z7stl*8BD%Inf z;qS-&q}4HG8{7k22*|M^!u{E`H&$|4|HenZD&FI)&L$h19=pv*F4kt)&6&lbk*Q8Y zhp^Idh?wiOym)6YRz1*9{6p$|Sw3}5rGm0g|@0Nz6JD=1tn!iRc?q=uMy;lyIXk9Z* zHq)dxk+QV(e+WL(BtgD4WX@mo%M)}#!-w_Pe8viJ=qWtgJU1LY(2ot|dYoCXd39UB zsk^3<1+5?qwiqEvcl2xtYWbB<-ArE}f$L`*9E_4RwI599t}omXyN?a5_BUZacg?mn zJSQ*kOei)h1spAGLYzGDDVf2w1?q)JGsQ84(%E7NB;Q;B#?!HyGb8BDlu4M#2d%Hj zC7Y+pU$W#96iA=*{y0lUTvfz`tl&7Vbj_7jNE@5TTI)W@o@9OAG!6-RT(S|pq$htL zyHgsArt#$wnytNESxJkme~Fp?p^r%(--t^-4U<4Jz+U-PfvPOI)fB1e6lXZYzhJKG>F>d zP&jmTnGkj&9GVT`H^kq#xOo1ORfE#km4;kqmRx#+utJ@Bzca8g_A*WA5$+ywulYNo zQZ%2yP37u4(>i7XFfu|l!k3zYC}QX>3j)}L%XzFzsF{&Z4_QHdg({ao5}5(N2XPhq z2{BBHS0@?C?KBB8<=!t6%p;P~MRQ~q|sgwg}efWTf| zZ&7UJp5xTe_tck`Pib*6ThBBGLC~~!n|8$@9zg0$hAvKKBwM>0dd#A!9rKR@Tfh5@ zV)=N~WPAHoljX&!#RwyKA9qsYrY_b}zm^i2rsYJX7Zx`Ptq2%{ul7{$cWA@&=lCGC zn(uqjyBrhv!cB@k#DxTpdxXOqzH?CWB)v7Q6z zcKUKZjHiT0#o#GMXh?@j>r#fH-Pz9924Q72iO@!MBs^Q6@pBl_vQcdDw8EY6-`7&r z4xTtAEF}1%o8*{Bzdb65D?YzU+?0Vb0$HU|h;NMPv1CoQK*%(FUYAdUpdu0N3ZZ8> z&C)Kz9kot8U#hkR9~@e^&ODLz&lF0V)v%S>O2BoM;Y8DREw{FQ5?tb*D2cb~StuC80> zR1yoj?UO&!4f5xXOMJrl#;lFZh7eX0ypsGCA%5jV3h?LxYmtTgF>9WTqJp9FPv-Xc zKp)>1^ML;6kk^H`*B=Z>HMe%7!6Ap&) zq81YLBD2xsj98A8C$+1=D|-BV$doa=*RN}zcZCr!V0n^@b8VrwD~tOUW{P6fNuO1F zabY~Mi=mXW=e2^^wwoUqcu*mJDrKz8hVn?-CBvvj1f#(}JHF%to>)GAcIgcYEb3H) zzpYH{6pr+)!w4Y)qQ>XTm78r&kXY_yv)7<>fa+qy`F-6JeS9KT8~0hL_&IN0nHkP=To^xI-1*u*>7QdV! zleuFK8BFN-^X<}rL!zIbD(LM3nMNkT*GH~~-W;J&gL3!R+PdLOtPiRv;?>Q>{_GvU zwGf8Y1s|G%44Q{go%m$I`Vo9+L;!piA_xBdcB#Z>fi`(gp)3(Sqr01+7_T7Pp*ZyJ z?Wfw-tpe&<rxD7buw8Zhe1ig z1GUrNcezJo_pBvhS2Pjz?-`6a{o1jwoF&CR$l zx*Sy<$Uo#qUQOfXd`6U1xmm0@BCGu?=)y{UR{Jia)aF?}YzNjPW%8!LHOY7_fpQHKrh^dIARHL15e?FFA{w3Etppkdup;a*9x_#`!{@dLSG-4Fd zc79F7()?e!kru2dVJ!~(nOBTRS|;5teiFB(E}ngrF%1uwQ2*3~BGlIf(zU=N8sF<= zR0C)UDI~IwKbn?D`d~C!e~QJ|LW1rB0@pj#rHif&`W_Ut-MsrP-ATLq zPx{5#)MXaEGv!dyT;jovnLXZSrgv{$>N-<}v92yzmVY+Xr|34a^{kvs=>04T3h+1t zxzdJ%)=o+E7x@R6w<9V7t=+m!`iIvH>ZlRl`nF&c_r>Er(Hrst=J(9eT za4*a79ls_m_^Z9kr8zvn|VkRyXMFkpCmeSRgN@F6L zb`urOg4$D|&DXcGxkMA;BG;y$ixpbFQXhL}>X-A+R98WI%}V$Z%z-pRWg4dDzIa)j zD6^uTQprEx+~0WpbqYyxwck$|O404Ur0T1o+b;5K8`Yq@gt*!VEiVb8n+qtWwV}IA) z9LuWJY+huZz$A#xDUE4X;?aPYnI(WHh=AfIN@4r~T``6E7uD8YEw7z;A)ujYrJIZu z$Spi?;IDZY__(SlqJZ_(e*QVeC3ax>*2ha{`2g}b>SbzcxQ1Y()O1|2i$PZu`G+bg z7ej9|6dcNr(u5YNTMDONex+vlX`vO;;(to{)=bjpOad!4vb|9NUIX$fZyg#Q)bLZU z%ObY+=~~M8H&>-7VhLSNw>x`QufT(Cj;+z)JDs_8J^%@*_}I9w;FJ?Cv#RmBZjG-I zS*g;&957@=U(_}tTN&^znv}m9x^I7g96eiND;R1 zp~q0nvBDvzn#PpLd?w^k?@?!$DXt1P>Y{r5(1zUPv5d|?fhA~#)MgR-z$!UnK4i3$ zpT|(H4E23TMDCkU)P$?uxUJ>2W(`l0{-a7S{?j@CKNFUwKF8IeTqin3jaOx(qxRRxn*OFj!R>>GKY6NB^(qx|V{iX~!b4q5&e!&d?asT*@+ z$7MkubLhy#`vR<1QuSM5ws{9?2NN4le0<`|#gt7kK~7ml5M5~n;VTW^rRUe#!#nnp zWFuIozCCgdKn%h!#YN+w73A89xrUGmv%9MrRAI0!lWaOzfh0Fm5l+1X1{)(I)F|<{ zlVojkF4ed04Xb8vzpxqL_2fm32l?OO0Z{dyDa{pxyc93XeTL^LbT99{X+6BCxGc78 zd^0c5O0rm$t)s%tAar|&d8@Y_X_uUi>k64~nA56*eu~Zm4NLIf%lfHMaK~#beDJZdC(0Q7%0z0hnR$7~twV%10ySM78 zk`y)xX(td2Asf=(MN`>i8HN*q=O3qc*fSx>Yf>b!z_nw?x>i8Da#~|+jLsJ5NEF+6 z$;H`0?u}gk8EBj)4tmt5Yk}4cik+)OpJqWU5K&v;hVwSX4AQ)xt*7|YCn5MgJz$GS zEH=l3$%e@j?@eo9_}xst$Wqd_PI!1%j8;}r_#?Y zGhnmDxosR`Wtl9pH3VNJiTO2ci$A0P@qosEi@j+|0}fP0-ZE4rPdbRSUiBpXrU0p5 z6^^7nxB4v!sakYV>FHnj}zAZ*Evuo!dEp*1bVM(OzA5{m4xexdpFHb} z4$ShtZD2OAPcY=Z?QTan%i3bNHJ1{|Go343_Hg~qHwe)f3mzV2Hmq#Lu87x@nC8^F zq>S;b0P~*$=t=<>(8EPfO?J)UPBA8LYA8WEkj z#6?Kc+LOcJ@aA5*laHwvNxY~Eih?~~ba$-pqm1}&U049kOtyv;cW`e{9y8{iJE70c zkP;MPOq>nd-(-Kte)=pgftu|PFCSO7Ay&lNC~vjemQY7AjhmqM`rB92d(OtFOOdXD z#C-(=awD6PaH0dfy@vytof~8wI$lriQR`H`)nUG>!kiEJp#O>+ndvN3coUWDl)0?( z@lGT8C*@Et3Fe07ax%UTCoE)Y+Tee==`#c=(=HAnnfBtJj$`%mCe9OuR>iwT5mIC}CR?JK=HPaYcuI13`%$>%$Te>f7OaIo z>6P=v`|0EShm?J$V%-YhOnn@W;A^y6B|W28Z`08+mm9AY(n;KF|9bT%o$70n^t`RP zZ;yzH6q405JHP(KZ8g8+Nk%zRA^6rtXtTi6;*@R09|=I=1Y>XabuakF1PCo`j&9C=>R@n_~RJZ3N3xJ2VR z!b}W6+?V$Rtb@l9lBiDyz21+O4OjRrkvW*g5~g!gm}W zpJHyzlp~q)UnVFmTg6|M`X(SxE?3~;Evr{sPB59}Y>=FZ8vj@&7u^rj-@}!Z3Px$p?9e;&05Tfou|?J~F0>}|CgMarOZ0HX^MIAk9(qr{aM4q>y9;v}3Fbz1_J z5Ug?0)(V#V=sm+r97Vxr?eN=B;>&FI(M)1?R7mj}d*jla+Zs z5eq;f;6Mn*Ufu&_n6=I+BFH86qzYRsP z*5one??X2@99_+KX;^X>o_KG;?cV$~;Tuo#mhAL*AQ8s=*i@FO@s9U@_RSB{vO~#+h$9K1q3t&d&};DxXa#qa)Vv37T!KS5L<)W)Ai&s)( z8U7=t!WWt;R@yZ9Goh+5G*Q}9mza>9l`Ii9z<|}M{?1_`RWQ28-5zmNH7m0!#Io`w zr4rU;`Q2)e-90`wzmDc=;Ra03G(XB>F7ct%2YS4aUGP`dR>sg`Jq& zNYH02pF0^@9MAJb7fTsL_g6W1T_dk}Tlo7n#2EFkMi+ciE=UDs6I>&|Ea6%Fc5Rnq zPtp0%>eYOy_=N;}K~VI5A@W#qIid|7t2+yoZYufg>iUoc^1O-j_hH%fZOgzWVT3TZ zUw_u?LUuA1?lQky|K?MqEx1^}tFbb&7WGduF1J z58>XSn>cxYg@A7&g!}xj4EP6cEn`|Z{EhdNmO~+ouWrx}#e9`g2`qZw#Pv1ClOmEZ zH$^7(h=)k`qiKEc9sBG3w923Ttv5LHnEp0Oe*t3N@H6n|!|n1dMH0;jZ13I5*W4`W2a0n6plDtH?-_L#)jB_>ZQczG7Nt zuh^QNuf)F=;4-syw5!X;p?K{V_i7-o)?WAWsk~i@n=dzf)!Av}v(VLo z^M_*S1WOE6zRLQ!wrNapm4)#;aTR+y?;&FXRHx!+KY98#bPP7Hj?XlXdp9nTP z_z&I`kW$gHd=_sPZm-Ajx6~DZSP8rmleXCPeNT5UHZ7Cz$NXuJ5y-8$U*_I%`iIrh z9?KswtI8_Vo1-##(tQ(l$Z~As<4mvntNdiFJPBvtv^aK)j%PwchHzBWGpg+bl~Vkn zE5Eq^@RR8Q6f!90@AExD##c#|wI}dOD!T$GKyOIKKO{Rd%X15S>Ek>J)*shf+xkLg z&gwQtVUYIER7yA+d39U-;EB(845lr|FIimXM@t-`O~&gG^0?Cv0!=k+?j0C8@CKx%#gPa^1=v zG6P$i4wgGp#S1tgNlg@gnj>|h)Va32afV88@SgTl{#Km0MHQmFD<+g|hw=YL4Gz}L z-o>#LjKihk9PwwezJgDFA1eXu;0=%W>oV=4MVZzuaw~DG0lqn{Ale9!K0O$a{`zUj z3gzZf1v15r;op2_HHs}2Iu~fw2p>1VpK8)?-h9L&r#ViNLm&@3yzZljk@Y}$J$c#H za!cm~M^B|;0tuUk@XMFQrgDhlX*xhid_>PplK>tR+y10BW&@HGL66>3J81~29{{{( zX(uiHaJJrH>@Tb%I+b=C@OF2#~_?Iw*UU2x-`G-Z1`@;kgh$#sWqs#XG#`W#FGhGEd z#6fAbiGNWr#Pjvm%{{sojezD+MBS!#Qk&{!itZq>`NR_jiU%`ekax$Mm*0{mHU4&{WS!T#%!(IU=#=eqES>#1+m)43gOah3xUoh+jJhpD~ifuM(I*Y zooRZtI0X#d$H(f6kngfA_?U#|P~L{)BW0MHZ}gjMD?&+3sKrKxR*@T;R!iuRy2k>- z1TWA50ikklE}dcW1r^WulUm(!7PbsSv6fitokA5xVzy^up?lBtntx2f{j&H7YKe=2 zU*4NmJ=m4A(wPu}o&Q9O2GBZ%WNFIH?NiVhKu~hV>>aCnPQk?vnj=YC96HMuJvWX0 z9oSEE$|N#Z(|5Ah7xM9ch%7HlKAhf$ThJ~aCoXlhf#S!LD}{uSltyT9Aj_|FKJ43B zvO-?;^!?h?ymC_c;mA%+-b?d9MILruZ1TIOUf#_WC4N5FLh`_dbw)v?uw}{!9h1V) z$yQ#U`=0(ohk!=@`zX|{o1xx}dyMm+o-Wa0_>kyOvW|7+|D-OI;bS$FM&EvC7kigG zz$`&ng*<=sS(=Dynugak>`E7H@33nA`Vh(S2)C!JlA|l_yYkMOi8dSGS5{*CBBR=k z6$VwT!?n5R2@MOB*Ymoi6 zFX)#Iel+01|ES{XAIPm%-L}(Am62J%ZBZbH#Gec;pGVVkMUxF{OG0dcI9X0}*iQ$M z;Obbm2JJ6yir)ZdTUXvh+2zK~{%}H9@Z@O4FZ->b+`HfHkq7O+1#HF2$MLisKeHb@nSaDJodRNj~>`(u0IGgd_ z0JKPyIWxgt-;c&uEN2>h>NqUHKHG$o&GE@s)SP@pIw|%Zd&@J9*&{Y3hDdQ?W)1oMApM#h_!LWmnc4Ja+$x*V1AFt;Asa{QD=& zF3)j`DPqrf1}aW&1)CqgsvzV#(X7JLMDrDL>=&(mM;2nRRF*%hui|}<>c-NI#G|!( z8(r^LA>z{jU*ke^KH#ghMo667rIZ*N5Bch7+Ge?y$nN2A86`c6_oASaB@EVKWo_2q@MLGFX zQli6BNG^(ic#2>=<`hKI5}n6hF&o$HQ}X}zF_vqqjT8V_MaQV2M*H-mcm4e&*2%HI zo>FKRklI2c30u>&Rt7*0VP$DE9K5YE%=`qGzDIl+tJs$iv#1%R7gNWM5c$Hvg5vh2 zRA3ylXneyNE#oFd+1@iQzr7}0=;2Maq8}d%|$5KHX(M2_TMl za_8She>B_TaHO+D^K~U+n0GtQZ{*otyxpHt-_~j;Et^kL+&+$(^7tK^O*9`Iyz5cz z=gN1Y8Dv~qDux!@$hM2^m#Cm??|CK{*UNOVUK3yW<%3h~dO8NhkEund^elk)p~+CQ zoI@hW>nw$?Xr_=-U6AQ;^Aop^4|bJ%b)QFLlocET#iY~(mnt}nNR-2=i_G6z#trKJF>-CEq%5*- zxGoh}u9pDH`1#TT&6c5G3g1@{9*v7oZ~p>6|DG@Z0!rTBL8n^T&4Por*MuL_HF24#HU~~80T&W{kEoCoLsv#`Qd(Q|F#A)khza>nQmuBM z5kAB*u6x+dLkbJspUitQ6Y1CQEu`*U+rUcgQY!1(6WY&!W4Pe)X7&Ed2aq_B>Kt}< zImK!#^$P}^54KMI_tMly$K;fxSCGNsL{9(!Dlz@y#MDG$jtI>Kg~oSRxxDtfFw* z;X!zFV4{%TT>d>`TiPanQ7+-NOx(^m_&pHgs<28Vt&S>QJ{`_?du9u41T{@Zw@kO>H6R_|xS+ z^&Eh>=Uq>J9G^|i=?H?7ar!<7wRY=QM2xlVs zXI1|}&iKzj^n%tBxdKXMMS?0{aAmymUALNwQR=92bMFc6?Om8@;GnypJmY!x#5zow z&7b&1n2++x=J~ONway`~;CG3x1VM%+&US-yn(_azFP3{{(f==~N(w4k%)pWlXjtQqL| zEn5}(N@Ejk`UDH>%{&|0B!r7=-s`i z#oAMsQJsq22dyCQ1_vs@{GDhibLr2J-FUVSJk@z46PE7bp-@Mjl8`^Kmt93|Np^?2 zu&9j0>krQhO);&9LvP=$e>v_7IulqsCgXoIY#||E)hT!wrfecK|EsTQt$?7&S>#`f zS3gJI7Vd6-<1A{kaF30$>;!sP(fhH4&vYU)Yw&>Wp0YT#s@er6(jV(=4+k4Jaj~XP zlHSits0h4XQ{)X8^618*;SyunirXM1P>hnnOC7J0^e{R=KsEPvX{JifLCG>9{WcZ{ zubG}3s6bYe)S~|fZ=L%*``>*C6}6} z67NEj7(>T+v_Qha<>PQSDgMkU+8w_T7QO4BKEg7Z;v^C99QqUm@!+8aEyt}B7cTy8 zH)7^!^+l__b!&W8!O*!v^5wZVdPmWEz(S`a`9t`)vT}U;S*22K>u1_`Q{c&%{Q>Q| zeArJB;Te@MF-2u$v95`xm75Cx^i)uK4%ta#l?~TPLYdSCNm1~F=Ea@Oxj@EA8f`P6 zNlSP4gy54I2o;f1eRR(?UifszVd0JYZehV$6?OEuq{YQw;iw`rjak@C$lkr7KuNvd z{WpezDg=jl+V~7O*L2qSppwd4xig%mqo>~5+qRaJX6+-LT>h1J>1wq3R1*YSxh zLM^i+!ENK$ekyIKan3xEca*Y5HH02n_LdK)E~GhEFhUEjcg^n+L$55?yH1O(D?ow6 zNkv|EhYm^WB2Z}{)fxDPwSkTq~ z8&sdG2LIE||3zZ_%?>AQ7^#bdGa0xCBO9A4sRhoIBictfk4d#B^WW7Y1-vN}pXtK+ z$LKYrQbssB+m#-~DwKCx=s63x=06`vdVoUjT^GbL6pr2sw@3S2=vONR3OM#j}C z)yRJR!Kl^PJ0Rx`lPEhQH_8Nbfy&+4l%@bHRit9KoG?f||4qprW2Gb5{^#+>efG!& z=(7@m+=%rxr!WoyRIuOC*pnZO8jR70o#O02Ei7Q>GW8XG8(%0?8P4*m5{YWFsboA{@PDF1z{I*dw*N!cKvn6+mAITf5EvE-!Rg)sWT$2?r zvO}c*Gs}AV$c0kJm2z7^&sAS5mo#a$K+rBLBNL@$m}@y@0?AxWII`JW=jA6}9NF|1 zIcLU@rAk8SP2w}T_=%mCj}%#`@LJI}6UpjGd5H5E?*=BsevdXnOZQv)k8h78ExlF7 zWrWH;4aKep0234&<~aNuYpCMIa?bqOQ%2s8yX1pF$3?HO>kxWew)6rr56EiE2V`kV+^J4I6Z&QMwN%5CmCD8eUe&gmRIcibX`NS6m(y)UZTl%s!0so6CQnc| zWBG#{+%2`iZah{qW)y!i7rK*qa&)mhs&=4`7>&i*rSQ>6P`f*3&fAjsmU5NAo?wu7A6a1+d5Fa`MI|fho1gAaMueGj@5# zKT!N4y(AybKHoscQ$=!zN`XGGM2e?j)u#dBotuN$X*KGe}nEchG%6eCuQZ?lp}mpqUEd|+A{iod0>thyC1 zOIR9xfRJ#0BqEK3l%;19s*Ses9d8f-X-js4_=Q@=)~BqsXWm|GydQ5JS<;rfF+Jnv zL*m+MyPniigM)VNGOa6ZqEwSQ*DQPp3!k_pR4p>iJFoEa-pNDzEtA(luyCc!qLEl$ z!jvH~^83O>@Z`%8AJ|gnZXt(jgYSnJjXZvZWc^GIzZ+%`5MI|@yIII&Qcy+*jQivM zqTjP}fQ_B-%UZGGD|alzeJqe07|dN0rZwqt1-dD+_>hJx--4<{0M#cJ3+SL-&>mB; ze~MooTV-3aELcd;>?ewklLQknaC%x<*d$F|qCf(h!dYEbb>3R3duftAI@e6z-<%g_ zr4K=s$bTVI)vj?u1(QPHNWg(-ynn)-$nlJGpj23_V`1!*SlB*HJajavw)u#i#{Esa z&nf*9VKSuGCJIjd4>ZUrc%*uRLymzpE;2e6QDc2^o?rVQK+fEvP>i{Fw^*O{jk3T|= z$#bTRlB#Rc)ov)ABc3RLbd^v_4}~6G)?mIt{el1G8QXC|(!Y8CcICldsP)rf2;2VS zvdHxK=YKVKMK~gsj%(k^#8}@x@k;oB_GZ;!)>8Ox3{pov_d$yWl{DfIF-hP9?#ES? z&;7QR-RXipgm}Y6XU1z}^TDnT9V7kRvG-N|Jcyc-(WBdL5uR6OJ~Si}CK^Y;X1GVTOz<+9S+_u*5msKYNeHhlMH!)Cg}85D`%PUvZSXypSuUhm z^(y=M*H*jnov()?UD708Y!e0Cx-PMBo)v+Yc9A@?hXsdcR^3$tc5r1ZO%rnpTuYi;z+ALqixTH9yh)Z zjcH=>N*#xAOHh?PW}+waLOsyZVd}Do#$@6({ZvIh{=Ndrde!N;5}n3+U`+gg;qS*Qhj~XYDpw3v!b0l&>1ocK&BV0p zw8V*tJ!Wy60Y@I<@(dn4Rk)Ul?`HSl6Hv&c+q6w09~Ir<&2KW?Bl7%F>-9>YT~dnb zYEi{dc(BQbGmRMzg>HC|AUnKB=9WVda=MkVe?<*+t+*vu`uPMz!J6zpob7)j)m76J znB*9c(tBc4Nae0$7F9Ls#cGGdleQnBX+r(r-AWOY-ax$yd5Y*RV*m5FTz5w$JSkSo zj7!^02F8=AQ$hzQ_Gm=GwIU213|W0Af#xppgRJTxfpCFH0TiE^etjIx5;xW2fiP2a zBHCHzLwQa~_08wNKd*hz)*mH9V9fDO|8a}#81YwVj*-X%y9kWP$xA72i6Km;wUP3_ zTS`?=tB;)Pwxh6Pi)T3Lt49SKk^2QN*au@;>K_mn0h3MX$!5N?=3J9kNxDmWD7A@q zLO=1UVj8Y1+YR08m*hz6wemfOP4hmD2TD>BFfM>c)Tz4qziTPI)YRp9#^+NmVCzfH z(Q}V1wpiY^WNn!GIj~C{Ax_6ixRoi zavJl=UjHR!pWM&sEyg93Yo|GaDa)sE$0YKp!8k-YS5mz6jP*=BN$^~er;Vgbmc&7? z3!%u0Kr}Bo1-@LW1b^09Q}EANH=cs-`m#1lj}`N=`%mbaiHA_~)-!R1q2UJ91{CPa$P zD>we$c)Cm*R=;at$LvEf?PDw241zgwMHz|hiS%>7>odF1%K-DcHG+@m*dzSfpC4W4Q3t%SaoT2cgBpmC2I zqXD$w5U^87()Sr^cQhZx=w6nHwh854T%0?37mJ09TPl4n zv2mf8Fc4QbD+@Xo8J<^>x>14@cs=q1{i%+FNdoxriOfeubS89RpA%okhaM~Ho5*}6 zv=*w`{ng#nFy^-1vyeDR=9LlhO2EaZPcgnNFm94=^9IRP;!n@M{L9aT;g5a@JU&Mm zS}QERdOvXmI8vW%2``Cu@gZ(M(K6=l=n?}kB7#TjsY$mIrSie5>ZH4N%Z>nAm|5mF#wFPvXn2o5_2%JF zx9|J-lSXFrgqg8Nm@(Euma;_{`%a~WNZBi7m&j0zH6ct>cG5yUN@X8QN@PzP#**wy z$ddS8uil^Eas2+&G0nlfT+4Z#=Xrn3zt$g%&5urTJe7@+K4pGc>d|MoEXRYpqwgQF zMk+2z*J7QKK3rtZb7P$+O?Ps%@=Si_hysQi%pj+e7g#b5l-+rdxo}+Wo_56jodLt9 zcfRB{TqRwKlo|cv<*4JKm!;R28awjfGJfXnXhYoO#x~(ei?(;#FmZ z25G}m*&`MrL+6x}bR|a12Rt_AWZSWXn_FOgDtgW&zgLd&v?u$Li?53wmZ;ueP z4hT`+zh|}fVH})1y%Rsm5vssa+j#n(9ssh=3q6j322P*QG!0+M-bJCtOP5+YWGIhX z)hG1_M-L}GM*#p8u=KV@MBj?nni=+IVkj*33FT*u3^m8fMrba0nKBur zeUr z9Ersq@4po7kG>=p?LGYMUeJ||hBB({HMQe2J9yjnKRj}BdvyDU;kVUKCqQ#Dy)Ayy zR&KUr!oY5k;{5$_rH4aLmZVu|9|>8I{do=&&g8=k^Zw#7Q@*WHp3Tn1R_Q$9?H$!< z+QS>ySh{hNuD&QxLH&aqx~2kIs|H|4W***o*zn7#$w#O3z4qSu@9HnjuM1B}%Uq?B zvm@sHem1V2>f9Q~%+e0^s#Kf)tfyKHkOfJ%M|_6zAeA4u&@bEX^)#GI$Kc*e8 z?rk)9-DGWGrHXoC!hTZTy1}Z;ifJ|JPo>0OBA8tgHX!of(WM^nZ9gtwH+OtHwlbm7 z_uB)nPdM~Za>7~hXM1#$D=0md6NijZB_W~8ObcgH>yR-BfbhPwE5=bX;=I=5WbrXe zkkbHD*)H!|5H4QIkFd7*gCq3ml}5*?U7wBpt;?c`wt4ZPk_52`0=@M0{daS~MAMhZ z0$Im+`%zb(JoQLWcLlxX_?B`%RzFR8__6Xp%6+uYo-Mao1D%zE(;iq6$IwI{5ap#e zM@Roern9oAvWMDdXgV(+dF@IqWwvE`oot0N@M2@5gh6s)7{*8Vm|L`4<-U|FQh`!o z527A;#Ufqc1FJaE#4+F$g@2!Ld_x@cdp|2uwOWoS2lDJUhNr!ofe)zKihg+Wn3TR! zMK2%f#lPN^vtgUw8dl>^d92wzG$K(kzWjF>#0Pgj2>p=_X?m2rWh%j(xG07h3VbA) zg{~0AYWZyFR1)K*A`*dWU#~K9!F`o_@`8<=$1SSrjxRw3UTfepsCeYl7tZg-@$v1^ zz{Z=klJ+QK;oY@IK!t_5=SgxFGSn6un-Px|qUxuqK}PR#8QdL@i7YlY1u>U(i9?^j za264`%5t<)yOAm@_C;EW!2^!EWJ~PGaMY`tz4n%04#Hp(*KqIQ`L8bBtW!@| z>@|Kq@~M<~yxf;IF;esG^2cXz%GegF*UKdA_`WE2%|K>55}!D1AIDH*tI2JM3mYTKI7AKv!L;;cWd0 zta-)$J5pz*Jf$v4Wn5Qmp#mAx#E3WH&C{4D5gzk2Q}SkrCP9MBib;MJ)p_Wa0huG` zvu^CCU1^aCW>zV_8%d>RJj~D|U#8QX?VgWXIPWnjC1j?VdUd*cHHAJmFfo@5uh=n-1q4&o$XD_#{2#OSPTUlQ^u&gS_|EK`C$mAC4%mi6XKu z7Vo5GbO%E8K0qtQgJA49szll#oLSR-JYlWM``YwmadwfGv+152shw!xsgBfiBJ1t%@sj~J7UBL_+`bN(-y^cjY!K(5H<-vda^kNtN{1xLb84i&z1mlbH0J5_dqfKUzp4EG&5zB@|wRR#b!?M(w| z*DLLA1&^Pyz8@{%BpzOP8>t?n6H;KtCUmP1Z7LIQL}v zHQQ%>>7&ticuHBXctIct@p(QY0xUG!GxU31Wb_hZRFYorhCi2rWWu*&Kk7{KFTQwB>y2?2o`3?#*hfg1%1z)32s z74pHQD;|3UpRoJvDN)NSB1ytsJl?jb474U{@7=$0q})Ln-cwmw-M!z8V^u!H8oSOu z{6H9R_?A|hXS)36M1d<45uEXSKenS(c3@Dy=Y`Tw!{FNIO=uFrYo916IyqU4e8yzf zw;~+^5&Ps>VshezJW01d-%y{3 zbga#GO)~|)jG9{wIG9Y$6St~Oh(vH%G`fix$MPW>Yye> zq?d$ke7oCgRH9agoexc#Mz>O5YHrf)?f8 zER7nup?ed=*HaQXpL~5LR@#*8!sX9!Ub%jO?Z;F5%4M5hg^*kbz5pH+(qGT9aqJe# z=4qFTuz4#kR?);c;D2*gT=kDyr8)lDTUuL)#mkqpDwV<^Ea-g&$^_00}*TpX+qxcdI5@F#jwG!R}K3~odGl5Qi4 zaWCt3ALp;6fRLU5sOw>m?u%YSxTXw9zOa(TE%HX@!?n}(!u=mY9h0K<)b#rFq{-lY zCZr$SybK!FGnGcMIO=KYt#MX-I`&5QRqbVkGtUQ=#U5m;w#GitLC58XedW2DmV53xc;DlKzU^Vnfyvd>235MRHSRV_kf7MMJm6kR^(4*{(5 zvDsIQNKZhX#Q252T0v+j|rQ71m2Ihz=cNeeY;3Spg|X zbJYD#j3;*+gj-#G1ls|cb!P4Kz0M?H{60LFCyj<=lvsNAf*Gsn6BB|(%4|Xf~x%z;-0m1nfjGjn(aN4RwytENR!}uI2zANB*Q8>sxiiUF@Ip!-kxAFSw$yzv<_6<>zQ|I@C{<_TV zdB5P0iq*W;iq&tcJrEP23w9?oWpBT6Lw6e`In?4;!_&suB7VumtT*5bNCpKc6%r1C-SSHp&Ywxq z#xg_6?Ijk?eg%(CUPzm)osJ779fhK~vPj98Kj8KJX$S)eVE_bKM5mfTczietx+Q!0 zybD0NZ1D9<30<)xfNc z4~3tAgenh3H1y5|WtAku__5ehp&CjWA$J`31%@HS^RY5d!o znqS9&q7fm+mKR#Zy#m&3;Y-tW1k}o#oO;w@ANu+GFoW z_kGo7((30z1E_ga>5bN>maA67L@6aQ?l{i6?L)1F+3U~jo)X`wRtM*uaF(k0HyqTA z>82-726rW(W6)TKix{DbndU>RKkhH=g$A}8oJ{OiB$Jv< z(j4_OtiAg)PBo*x&xqFtFq6E*Y!MS3wbrzgQMW-DOVM|Pllp8QWVuu`6;k~Q!K5^= zd)B0g2L2NL(Q{`nR|3l(Rr1;8VDVj1TL=mcyh=wHFMN7lq=tDX`8#*>K$(OBoIEdn z75S*y0zKs>0Hxp%;y;cVoK}Zoi^bj!u`Q^F8pudBzG&H~iC4FY7bt)4VzF~o^Wh&$ zp;BsDm^pw;p71y+Bt|N5r8vjX2o=5HD5J7~!5RI2t1)QNygYDaQp!gvgHR1ml6PxY zZ#W2%I?cpcXl!iBi*ZxG%Yp?|cAxR&mEnk!Kqa-3nqK!0m=)^zlC4&xGWTJZEpU(dv10|U4ObnquXoLPh4n`YVXTFVziJ!?Anvh0{(?!u0}PZ6 zsn%>M#2sGC>q|f6Yi1PxZ&}wO@4u13%_S=r6y?iN!Hpm%P*f@ejnbj&Pz!R5nfNq) zYdLI6$DJHwE7|5>FjCm}qWet8wc7^_Pc?$2=^4k6Wor|0PhKdDMn-6(b+u%*#ydfJ zX=>aW5S$JgnKIC*`Qnjlcpkj`lVytWcU;-a;3{%X5IZ|*N}7x^uNaP&Q2nk}Y4{(RYj?|UV9=0Q(wOV z{6J|brmxHbl2UY~cr;z+uaei{qa=Kp)7;2;i%WA z^fN-dnQAcO>&A8VK~E&`O0D}AEpPgLn$O3(gq6@9*jQNJ_|i!v%nRBmP0*eWdS7a6 z2!IN7-@ZDvP70it4qF^uB= zCMaZ<1H@WFmgfm;>wfa}>GZZBYj2*UHW`b?5m6N#q%lr~EQ1X2oyMYqURxwo8iZ%0 z&A>$#0uHCw!nlz}UyvbE0w7nMQfzG56x$q)R}l2;Dr2#azwq$2fti&BPsc8;SBSt6 zX|xPkDl0*;o$h0C@uBozOD5zPe+v?RHHwJn$^97ulp)h{Pk`4$A|*!L+ionzPnmK_QwhQBZO__VR0Pv++@K z7rEKL2^$OMrOV; zhTlVSy(G)12Juk?i6)N2Yww9CkYwZ+P$w!mW&Hm@C~+JW*VNhYidb&pJhJ?V+vJ$p zvBSL@$Bv}o(s;1$tPa=$Yj}N}OC+S_>t`7)(9u|jL7Yr@XC0{Pr$^V0*pl=<@_Q`% z2sOzHfJ##KK9>}>nu|OaLT_0vP$pCIBW&Q{`HG%BFT9mf{a!8~I`pU(Lv}Ex%du*( zChIia;WN0{g|!xVP0gW-=4CKZc)IY{|4Y)~WC8O>Fx?XHH-At||Eodwpr(UgUXFT0 z6=Gb3R8~Zr57p2qh3>gO59+I1n<4mzJNq7gh_x>IO&ow&0L3GO({H}B$A*OabzT1l zFv%h&Yk6K0nk_FzYo{?Lsp#s@eV}tsr8;#(zHuW-e(DMmn9Na++?0tH!uWt~iuwbN zw)Ve8auqa9RtWw{`pA3-E^9v#gBsE_07QJD+$69fLt->Xn>kg^Qv1&`7&xVw&TlTr z+kCN_vD#y8Vn#uAqdiG1i$EBY{B-YtIqF6?=isJXg?rK!#Y{E7{I!kLp><8|fu@Yw zO_DOh^q{M54L-6+!p2G#i{^YNPHFpOP;iBsck`twTyiI53TNB9aM=_ZSUsz)r!|Wc zB6~T_ML*rUH1Vlb3}8wy{~2(2{hu5?f&ITchT;GkTz zx+iCn3VlE~PS$w6M0(6l?6#6e<$a}h%W{AHB9OTwLRT;TPl65cglQ1&*9oYUG6xjz zj?u9?^+>?ZX3}h%Q60VxS*vI5$6J1$odlY&WS-(Adz$Do+1h-s;*EHqbNQv6(AN#; z(a5b4mjLFLy&g2CL}B`ot9Smlkebm+`>Zj=Ub_ABz|@;U2N?e&?}d!S7B-4rwC}77 zTT526+F{LQZQ>s#M-d{Rs;o%*RzlVTR-_Q0wEAR?;kyw_=B;@j53LRaf#+=4>Y1di z_g{j(g|m&8lMp*LLx{N9q97Y_a^}|QC8KWKrnrLBiq%PLkwUB2{;SD#TX*bgZtGI{ z%-dOJcFzJSNhzf(!d0ya}7m1fOYOa;1(*_-E4+*$ZGj!2-G;)D~EU>Ah}S+=$Dl$@bK4l;~WfX;4wp-3X|t4PAw)makpq)aVUtIhvwnj$G>t1B+4f`_Z^0TpJMRCyfWw1U;rH3n^2 z1tq2pzP8959y#{lx0#fGmWmkwp-sGstEa?xjZV8U97}472w1?0IU<%}04aLRi zfrhUlal|;m3J|%mWarQPwVmrX2>SEwi1(}6?Cp;2ABWe>(NPL(XTlflw#Le~=gPLn z&qS;~+07E8J8zr4@h>CkS)r)&?@qlsLG!al2ebR@epP@C%*Sc(umcP)Vnx~Sl)EE+ zJnPya)2CWVQ#63K=6N!b1XRc);7k)I%+mg=GCyKgtd+oWc>i1t?N;ZvMc z!~X0C=K+Q=l{{AztNeyvsWLBltgrCN`0G7G^}ic(6RO(hKt2rLe|)sm*o&z@JqTujHYi zCibqZE^@Z(3vJ`sVZfn+4PZaR`T`dj8%MR$g~UfnGs31x_NrzIxkx@p9^4+Tl0Fw4 zLcT`U@$Z)u$?0B~oUh7mjjgxtOkk?m9y6%+a29myxL?TJ02-o8+1|lxLwiKl_va66 z)NeO@C7!DdB;|8-pFVw42 zMBsn9KNMsIW|cDeV`IhRp`fNhN)8XI$&^WZ98R|huxjVQX@8|EU`2>PHe|fzGkM^g zRQ$ShVfQ88M^H@EEl7Q2djaD<$dP|5iVUKd=WBACVvy`{N=8BPG6elF=V}haZ35MXbSB&7EA5{9w_Lbr%mz*Hs6} zkN(Gf6~lsHauNhKf4IN4jHj}t!}unt$fy!&mC!ay!!@N)0ZhlOR`NRFAIcXaCPR7e z3tk4oEFfwH5S1yLrAK)BU>V2TG$c%e4;nlFq@2k~BBj?1kaCH)8Gp|z;FPzHIG*Pwy(~WU<7kL=lAjZPyuW)M*A(&`OK=PU;k{^w#4YAldaxbMf_(G|Jy=+e{e_>Ryt7YXVS|d1c<#IE1}Po&b@+#?{7c66||j_QWiRD>G)ed z{KvTCcAY?+az&Hu;HGW$r_hO9$3muaG(wR`Yb^}E)Wn-8s>>uxkLf(&mKf=OJuoZw8%HtJDFxGnxMsEluIg*C^zrq1ZL`Z{$}rZC8c=DW`rQ3OCELq4n{5l=Yr5RUcy zg0ik=*0X!Ja5hRQp*550w`^IBOea|JD=CKD<}o}yKRC8!@H|{+S_u^sTxq6@ElZu5 zkhRVb=$BtVe+%G)bXe_b%#e5y*d|5Zll4G6plR43!FwC2E`CXAmHjdES{t|i8#SlQk*E{(;LYY zc67`E8Z9;q=7Z9i+F}e334cRyyrh=`OyyS`t$!ee$vSj+d;Bg|S$2(`7C&(mijz|y zmyLgHwy*H`_5~yZ$}OGERR(*qA3h?)S1Zap6GgEP{;F5X|ME9Lu%EeV5i5LtH=I&9 z@D{zLV+9g#OAtmTpbzx{&fO}6Sw-(%$oAYE|9qnJ-@M&_v5dfmRl&)o2d1^rnW{LV z1lXJmtCg;64pR&}A1^3=hrHJaR2@x*q0g9U0zU}?^bUl5j31Cgyon>-?uU%)%ZFlR z`v8v&3Kp$e%Lf##MWtXoS|uxAD9p1^D#_CdE;+eZ>)6p~RHR7coB3TapLmMMvrAEj z>!xo84XqAx9@%`Bzs)-Zd`_t1j;Z&mpjJ5M@5kyb*7whCjP3k!HfU*na4B-_aHB}@ z{G}OPUv8Zg{ac4HCgtzFU)|89p1#RGkutQ&GM_1x241^TH@LaUV^+TloQ6IX<)BP` zXp|)vfNvEhDIsN6G$2JlSngVJF)j=s2>3SS^@iJiBdE#Jk(d*GK zzC*AIu_3fo$Qc>Nc6=Q{2QFBE0k(Jlk zXtr? zy4QAO2Jiv6t*V{C^weZzpvOhNYB=uw+0f6cj!SWex48?=drh#_=NrUQ`wiG>y{DiN z%;jv)-%*l@7!P}g^vSAyl>hd-mMwfeTt+x9A3Y_?S;?KI{Vd=?HMiMK+ninE!D1iY z($Bm2Qw?g3Fkvc)Pu`Rjyw8kF3?;u>jhxnN(094=$VeSWxn>y`` zkawoF^H+b(gq*A2`R+Uhv1@>^1rxnXX`@=um?RNueV4a+tw~~Na+-c}*qly9u5^5u z=_zIQU6yOUhG77}t%1;wx9m>%_^^E4lBD(qLZo{4>y1YEaOKN^m23UAwA0cQ=V&zr zH&@dqWU%pj-&$O9Lff-xPQT5YFPp2d!&())TLD608V%KaYh^aE|2Eorf^ zJ~!(hN3Q&wjMvZZoX?OG^dmrtvitCBi8e8cY}EgDs%UltS)d1bkSgJ0-d`Em5y^P+Kd@dA@xFA-svs-o0ACSQIE@bWp2EcYa z4fjg_!b|`-t?`c1413FP;S&oyVM@*tm5qVs6C0N+Yh#<9J`4DUFd)XtGgP4Nm*)zW zz=xiY1y+;mtv*+D1zwW;1q{%4|#w z756NB`E7f>KYYzjDvh81QoH&*p>(rWITYxu`i9P9gy}4yZUW4HzJ^4J3fx8$MukUl4Wsy zDcw-Sg{LVXU(oqL6VET^{*OKF^E3xlbr*E`$klV#KuEtev+uF?x7N1Hf=gqa*cAq%z!CiK<#Z1=H#b9+TH`1UmFkJA-svDhPG#l5HOkVMDKge6#lI#3xD< z+VU1h7jlWdo`>ARo8=Y?On2~`gBbz9rzL9Vt`!+owY?|Repw){S(|GIc}M|8I<7R^ z%-h6nM>qbUn~UJ?y&oT3{Z)OGnH9aUEFr1Ve=E?~-1-ldIJ~fH?5pLLmVOK+j+(QP zqZ-<@C9F9m%G%szIJg+dqLX?SF$Ma&D_^4fjX_yZlD;+B6>-fJNIq=$LMF|dIPF*U zs^^APtg402H}sk`4R_7|naEPIvz5al)(z6}BZ=L|y*E7`icb+Kat{j-4nZ@PVMTB5 z*!(2hi3(j}#;lvUdMvNuEv)+wX&Q3Gin>`f1bOa|P zr1{1(@dg`aA7m95x|}-uE0E9B2Z+YRztbUBwp>D@TTv%k%y}<(xAhAOzlB~b9lu;T zSqYqG+sScosQJ&1g;xdAj9cjo>1x8 z(u?eRp93L5-vMX8gBRH~ALcXeP^)Gb@9S5-1NEPw1J389vap>+>$cgq_HE zRETuzS(v2|4%N?*YvN{>HxZKs&HM6iXOshN=)3?njggfJ;!a|6&R2cHaL zEfP21=W-j!fBOCYUlt8{ov$X$e@CWp|9f8qp}I@F8m%-%pyqUshPvM$H~CKl9w8(I zrXV({fVqDWSdR=P;WqbWvLeAuD0n|Uzc`58o}mPjX&8KjpFK(c$2%ide)AV!N#@z@ zFJC|Gi)8k7_t|BGPh;CKE6B4{1e+aXGQwByb8>N3uO3pAPLr#dxq?6pxh3fjGNn@f zYOWIW{_uRqk#JYBbz5Dl*XHos^c?>Y*7lK$mhardRv#5jd9*n8phEl0{ypK_Zr~&x z`BmjuSKsb}Y}47eQJ zVFPjt`$9y)cs@-!#b-#R?Jm{2EQ}f$b_V-(w{;SXcA@A4VjLPCnW!u(&-~~9sGtoV z@_-Ri)NZ1D+lgz#4cPk<@X`Syae~C&b}DOEYZ{Q8%!jv-w-Zj?15N{lBH5E|Bc?v-4{n^1~|>@^XTwoUxvTMg|LaPY&Ar8GGUua;9nC_!ExXgj z1m8VX9mAw1)b@W&+%Wwgi$wkXL~>Zo=Cx1n%z(fWT^ZJ%RXa)vto|kH&Yt)WQlH;* z_!HeNUataBOr8WXmX+BmD zuwwJrs_B&2KovZ)sZ+37{wzD2He9HG7_f_xD{cI*H-Y>Y8IRl=2w~qFiqU@Z+osbM%boTm&L=iWgf!W`-aSgUJB3SbW+pB zcbno^hV<7?r}+Bd+HV%B#h5a#w+D*tQ{M>MeN7_XhxOzu#fV=(>hs$BAuazFakJV! zo_4F; zuJ2^y+Y*-g(KEL93O>ovA0 z5z99}USY)zpYAs2G>~|08uZhh4vq5jdA*D_jCwfZfN^=CRuVlJ5aFmplFX* zOBGo`%kW&Nc-yeVZGqe~9hqiS9ex>>dvaH31`xqcoc)SzOKn5{S$@WMxaKo!*Y#y~ zb4!Y?gc>EqSE#5}6q}Ho3MR7@*LqdutbfiMJkS4{Xj#@N6s;jtdA9Jxn?;8cc@7V+ zKkPsrvH83IO-mRY5$=>zmp`Cbc(|1-rF2(EhA6nNk%iLrVVDXJt0ci~QH(jtBa8VYBBr4gJVVlq^uU&XE+0f4=@zg}}c@vg=|PEz;l?vtX^3=m!67)Bd?PMgH>k z+38*Vn)i<73rLP<5fB!cmRgD2o{QZ>BGg$J}9(-zFHnTGKCZ+yIL!7#O~|&8FW;oL|Ry0;yP8Cm;&%!lA<_;gZH?fvOn*vHbvXjoXDqo@=e(JMl2| zo-%8*YkM^*woZnXYYN5j%&MD3jfaJ^Q16swO-}w>D?g@DQRq^cEU&yo!_oh4aWSqJyx@^EO^Uet`#Pi57kGTGe7Rk^ z3)fRB`0dZ!#$pG4u&0)yX;f9Zk=-uIND;|7g-h+n~1ro=_jYJd2*n5m56{(QjcO<|@d~p`CUlXgi z!m`30s4kT?R&Gbyr>0f=%Id<0sNcF^3M%KUaUJt=^m%h^l|WM%%1~U4!j^2-r1@p- zLX>}Ot3|4$pTFvy3I^+5o4~cil=Jcb^`yWwa`Y)*IEs_U0?cI5v`ftxo5@>~1*Si2 zY2@iYd7>!>fxX9BjUvF(<*6LtEv)E{;!b?kP3!-{s8ELl7}p!g|ECxNuIUEM@}vWT zy1iG7NjM%Zn&OYA&qF#a7se-s`!^@*LRRm3;7pwM4$}tFfp&n7&6sL#Zhc+*^jYg} z6NZ9Hc&>Hp%-4&%!z|ZdUS}K92qRBVrz`&y{?(k!+!#g73}pEehbn}HwhfiS78j3G z%G{&m&CRbn8Pdt+Wd~?H;~)3OFzxy86iV+@B*|HuIEBufJSQK@6K<36=4?Vjnfs;2 zuFubQ|Gu4Xl<)gEaQLx%pgeYc*}z8*MKRhr@^UxZg^HQM8y+ujw_y}HB6FxEq9GdB zbBz|993d@A++2{{5pxOUF>@b@M&m*vi9Ib~7^bkFPe`X=`sR>aHMerrVU|zvsJuOw zj}Uvl?vc9@C}|X^I-he z-#b&B7Wir=^dFnTPb0oMLke1}XTk+LX8S*z?*-p=KqR#LNd z^YT5_4adCdF|bs>>>U55ct0WXXhF_lN?WY_*6KDT!xmMAU#BkwA=>+RZVp9Nu|FxJLE%eZMM+wBQ$>>kT9s-Z#3uD?4M30b0h zlo&3#vbygseB9v+em)9X;Wx_j4}%{?Oud;8ySfnLUc+PMD#5X!n2|4sOyeics+(03 z+;>g>&^1reKaFyWPOZ0OSfcExr6@PUvOEnz_t1f8Wwc$?rmN%}W=zt5o=aJnBWB*$ zDtxetFH5c0Y$1zlCVrC1l&y@qp4|9s>cxt$QI1xF+0%?{rz3x^mm<}_w3e*%vhA&I zcjptqt`mt&?#g^$+7tUQ`Xx^7Qvf2sZ++dK4acoHyM_^wq0}tk?ayiNW?>Uq?n#5L z!Hzn>Gw^7KS2qW|mh?WVr>lvs{AHx@0mj~P6CEyt#PJaSA8o4bmz7^~mfyCx)NW<= z4}Q~=?aD(g&_^g}#dkyDmAMd`)Tv~j<l4BT^OBr{g^1RiXPG^8GEm(zBM=`1#o$1DeOe zL4NBxmrZE2A zJD=7}qQ5#yu;pCn8g~By9|NDxn0*_^y9vC-?P?e@>g5A7Jpkd zvYg5XtJZR>^n^db(@0KEf#-%?`=gnZQAvf#xSYwgRFSpiZlWObYMZyLso)jv6?;|I zO*BX1sE+{RArs8vV8%JLQ#&^v-0H51c?1>3of9ZK8qaWH7_SvF38Si7 zem5J8ow^G%=`>D#|9Pi!$#YuA%G=au<5}#sz+%MB8p*);`!=zb_dlI5&;ncixK(Ih z9sKqz3HQmS+;VIdG%DK6Rn!e;a>cA*&yj3!y;|&^1QsQhSsh)}aT^pUqh7-#^V@Q3 z#O9sD1Fd{J@K*~g?I@WFd3FEXPetj6FY z5s^nhSWCqn;9Y=J_d~`s%yt}k)XeD%?Oztdh4dJKuFwqG;hxfrIp$g=vR0-GvzD%~ zx5NK2WuZAM_Mx@QopxaQ<}Lus@B?aG?=-4oZ&KsyQ9~bdABh6|Imb4XgaZ`FYuD-A z7A%fkC%&D~R+mf~o$#3)#yn`Ah@05=7LTTbX)`_`3?enfcqCx~(7vw+znNblOx27^ zQkM3(TQITC(Xr}D9d25^BUk}31j|V1h{+#q8xFlKa4=h2(2XVN)>T7SK%Xu#M501p zp|sy?In^kdVYHFYvd0h)P=tL^L&X>QhGQt#h}~$1%`ZQzhgHLi(WU#w(I4(1R&HNe zmU3k&7ldLMi^RhVd>fK zYxiADQ2Ahsk(3(RXHTSGD1s@uEN_od&huP}JXKwIX4TUO_KfB;9Kb~buQjoW<8U8) z4PiitZT}j+Tm*J~!!w`D$a z`{Ja9((n*dh(ofgs7m{S7p9iWcicz%l{D3RQGLn-FkpHAh2H_BpF2_J+4 zC6y@We1+t8*E+Xf4~t3_U|jGKYD4~L(#fXV0xA1&a;ilXiE$wVQ6-kdzqP+af!pBS zaF@!UJ&s-GMr+q$|M~JBwTgSFhqD>75n}F|>Y^+ts7>(kaWZR*UEj&nwsNOqd%MP< z`S|c|A^t=Q%#xTaBXoX)SP+EQa7DDQy%{Qqvr6IaW`^hznutc zrtNr5yY+cz<`NyTltDk?zCtv;B^Nac>K?1+#X{8LGt=C!M9zm6|{y>0u(*vTeNboTQRzA*jDhe{QXQc2xzE$?L{B1Ho>jXG8J0y{Xg z3A42F#sv3qwryR9x~&kpK%1T=YH1X;c=43mAxmcVT7__T$UPL`TVID&8S|T$G=%z@ zZ;G@j0zNF-FW@J>iJ0K@yYTCk^0#BZ2C5GJ%O|8wnI~FAS@z9b3*71B!q5NgQp~7e zx!3M**_kklIR1I2%9yrb$_RMl8`vS7YST1VPBAqE6e3SjrA#SVq9CLdY)by(CVeEfiS=mNv;Kl{)>)BDz6uhxz3 zcHAVe87#CJ`q5vqN?efD3=qC(r!BITuqXKDD1P&F$_J~x+PnprqC#XDg5|#U>EH@5 z5mo1dRH>J=z6~|4H8a%qmf}*$w@R5;f9&^R0a6pPr}j~!#o81Nc0-?23dEZtJ-6CR z_r^ry*Ushp26mb2E_{r{`+QWz#@imGkH-$bM)oUU>d(C0&8Q|Qp%>O~m)2`88!p&H z6OWBF84Fv8XjA-}1R9PC5{zWmuv;mSf#%9fQS*cWBkI7Tmqyy0l9YKJPCco6?B|h% zq}R78lNa)0%USL%8?HT4Pk((nEPP%mlGg`F1l!^31BURBwPb{VX`)mfjDLMt9&sLPXnjDy4zrQ(;c_F>%u zl@D?CJCqb)U|UWR5mREKZQ20n?QrdnR36rNm_eDoDRNLV$jk&=aOD3sH_R3;=L@#| zmmuKmp6(a15&XCP`sZN28lU02LUVmiflBWYi2#pMA@x+iINL)LdUW!Y12>C;x7L50 z>LNeYa!zl$LUn?`dDIDaY*Osx8rSKyrilKeUUO6lji?}veVo45(;YLk?n+71?^%ub zp?6xY=KB-x--(Sqb$_T{)v-Uxgbmx1xmgU;(wC1~_FH<}#JH(y{oUFZi=7hY3wQ%6 z|A`RlNK=`^Z}gsc#sicR)3CSv`s|bt#>SA%a)EfmZ(mhifu8E=4WcTJ?litSVS~5F z3k~CoY<5=tFRZP0#C|?Dw>oiC;H)RI0FgsW92$iOC{YZgtvxWwUd_`Zl2Ur8s$P8U zsgbZcJO|^pHdcVS$B_}++f|CWY)2Oe7hh2oo=tKJr?VfiR~O_xe~44^gK?L_?yams z?bA62oWF9kV9a#&P#G+4tm>1=PP>rb<)U5k{-#COS6XXW`!jT&O(K(z&5*7xb_v^N zEV#D%K7hiR5{b1Jpz}-=XcHXt{F%UJv(@;87KDA%_2K%ETFlGqUwGwZiG=Z)#VVa&omxK@e~>Q8e46D` zwXqzdlYWR6)5(RLHk3uyO>|()|99aOmaeX?O-Mq(trjTd3`g~gxUrfW6<8Nr7@a}+ z-7d|Mzvs9|`2)LuHyj%=X{gSN-OBIAD0exs&&h5=+&_=x>2u*<2%qGWm}G?x4mrM5 z)yl0%8>)a1Mm~>EE^MiBmc8-JX?SnpeoAQCiQ!{PY9^$c%6+Gd!-17s-Gs$&L&wE9Z^@H# zH9yliJ)vCb8(I}Ez;f388a5QU=KqyL2-7ljbSLMTeJx-+xV%>8Mh?! zOsJ&2Lxi*mTjp8GP;EnDD^n7gZ3>CZWZ33JhJ?&=DkKq7#v)V*W%gUQ^E|)z{k(sj z^C@9}zw2J>TGzGiYbp4zi`0lKv`Bo7cQ+ej&)UTq%p4Ly@1lCO7XnXcj~Op!O_rRw zNu{Zk(v*8Z?NjUG|C?W_252=&i?Z}og_D{snq+LQs*>KG%&|jSd0x~^rTwn>u2AQhX`+owd)4b5=?jm!;icz{-?k%i z3oBUd9zK9SLCFwc>6{Zj*tC=WZR;Wa!=x36!hdN4Kj6`87vChDC7iOQIY+t*#pCVS z0Ciu&YKUW6#?RRnOfGrbi1cMaBy2ibTQaCJe9bA!wow6^^Lvlgr?p9G=TC^<;f*F> z7NE{82>Xt=$g6%XjUIhfSMVZIgSdrN9d{{hHil~)L|l5jPxnIQt@RlP3u(5Js}?hd zEe=w?_3pnWw*A#(=?1;7pl}a^nKoNz=fq*^{bCcX^M2U36#1Pdk&=W6i(|^mJ>C@t z_AlBnGgoEqa4r5$47fn0T#`g=TnXaT^S?IXX0-Y9o`k6C;nh9UEoO%k=!CBdnv>7O zDxUUS6dB~xYZv&jX)>vajQwh{BCdD2OD6By9B``RL27F^9SQI_21g6br~M7q3O^MpqdU9sX3CwR{w0Gq-@A@GM2O#y>a8FQ>oaziF4Vf zvPW**g{OpzsIxx2BHgM?e%KVV8#=0mL&JQu+Owof==)*}i}z&iHKo?GuUr3j78_F0 z8e_{3saV9lNdcQu+;QwU$3=EM)&MQ+yJJ5hg1<(}nxVr?M?mFXZ%iad$bQRq)#|-K z^UN5PAG^D74V{8?4=?(fuGRe3uc)HR(>ze2>t@3OI;DX^db9V8O@Sa~;?JcEV>uo7 z&P*;}_u|uH=f(J|e_|u$kN>~$Kma2;P*D3i9kn$JS|<$NK_>9q>|mrMDhtqvp|gTovQR7%zd|?63*;wCkj?UnrvY|^_>=%!1(v{MnlRmA|d~fWE zdpEJ-<&O-*CR)v#R034dV?mcObjLWmkMXK*vB89{{kh{p-3&Nst@f4T+hJn|NZnKS z3cXZMKpCq`>~_%ccDnl%u3HWM(9;^_Hac&=DoLV-yHQt3ynh;oI;rweW9CLbG0A6 zZ8;7EhW!^>Z+7e^qs>ZGoQyfON!#Zqs@JHe;XaPm_9Esqs{?@D0utI^w zaA7Rl6WIFl1-fr5Wc);v!f6)X=p}vs`5ZQ0o(p>Qfrc=-uze-EczRXj&M@;PzXr>N zOM0Xh2cBjY<$f@oC)>&>wB%c_oqDXRma@Pni`BEQ5E1oMVC$s&#bwmV@2ikbXVg61 zF3NV1H_cj3>3E|@)79pChcZlpawb-tq6OOzblxo#Z2z(7NktnhqTt>cVWP;989CW8 z)c$mdG!HmANkDToU0}7VwNruKul-HO8~&Ml1rBY~LE7Yk%%B)C1vy^7bMNcF)LgLV zasM*H@?HU244{c6h4a#z{b$IB^u!dTe9^aoa3aZ!?9+FlxtuIyziKy* z-n?V3^~JjNdlB1K)XP0wD5p0&gmX1l<|Zlgp8BtOy*D34@9y=&H8HWVZ%o1e{puNa zU&pAisB^QcSK;ok0`b+kl8a3I_dL#_MiA@o4EemPK?SAr)r6yj zVP<%V$CS6**wKH5=(az%T8Te8RpMu6nlpC2wvDCxJ-{*q`|(Q0=0bkJdj?u-jw<%NE6Z6|9I6V@F7sRfb-!i+FC~uq9Ciq9WMmMh7TLSGaiTXU9-ylKy^bA+p~f zlX#gAsRpqe$PcfkV-ChG=x0qsPS>M_opv88hA4u%@Ojl=?smPKpQ#a|V=H9yp8cG^4%9fYSKL-vljOh$ICd)(p6X!*cx};~8oq8b zvg+2Qyt1qh2rWxpaCM^MpIe)JG37pXhOJg)+t;mxuc0;THpgFtX#jS9BvhW6UXB;JMlC70hXKezeg zz5V)f%rhmgE}=!iJ3x5!)YfiL_}P(}SqQoT=$=L80ldv96Gs4nAHue+x^kUB3YrDk5HV%}zC&fuqBx~%Bm_55ekd;4x% z1(#jQs8ic`(etrx^S~bLI6E^B6=x(r^~1~J3(85nH^VX|WjFhvJM?R4eR!4ABwUS5 zqoTBYJS?BpAX_MCvMss!(swe9LUk^P*!lC0voMLnA07K{n0w2Qb1R?z9&~PV_aLko zW&FpK#8x?lI=L>M_dSrC6rDL%^-_XyAX2U!J{KY|4O+x%PT!?;9CA5a5w?A zzm4B6G=%;a79M{IFd)t$^WtECJiBnU;P}7b`=*4q$%9nTs^9RpgWl|1xxs|n`AsKs zRWHxzf<+rvep~g`vy1jGTZ_W0&Xo{^d)QW6ZRwgVh_+d6DG^5lwCwM+JvyK!Xdzv$ zIH|Ambp6l#6}AI9Le=H=i0FE`?HN->XU`MJte_R0_KijL@8JEz0L{KJt-A>&~su^H-m~EV6pPwQxbFt<))EY>(b-%vBoKKW*ZUW6AnXbl4vD zW}dxzp=-}mr$rao89QPQ{c^9+){^Fxq5%FYv*~1bxMBCuUhyK8AGIVgbbrH;P?kHa zLAdne`LAPRj?Ysalnx%!c^ypuqVBM1&0;|>yJ5hazW$!6VnIYN;4U+ z{i?gMvt$EV+NjhiGz1{?A;C*Qaf zzgH!v>yJikH}etOv!9?QIE0P*3`m&EjKxCp>d)JJrjt(ZO0}k5h?H_9F2?3q(B)R% z7Ft5EeN9Ju;obdu`fYNJ5<@dY0d+;6a!VFpIi7I!;0_carLU{y##iPt^VOO+ljx(9 zji>|;BXDq0xR*-v8Ax&xR|Mhn`!7fjeAH(}i6bPJ-YZbRFeYuXEe4J=!ozHb)@sZaCR9ZHsL-%{Letu3K`pKmGnyZ?ri>t1yDogP=ZlBEV&v)8xOoslvA8K@+<9(}~@JB+)3yZ_$ ztM+n1RhT)EUY6~){rkn>{})-{cEYN83T^3`CG%VhS@+{5al#6x$L`WcKiud4w=QGT zVQ1B0b{X>Dqd`z@j~o$2b-#`0uwJI#YHJ3;&IAjcGaMLP1@Ig>Wh{sjZ%+yw9d1Am>84R zk~G~+jCy>(xS28jE}wg)lMT+G=(ux*h(ywTk>-2`Ub_^gu49MA4r!bW68T)3cc0$r zpFCKcEM5Kccp9r?o?7`AAM5GZ!)l#*8&Zf^VPniK0w@P>>TjXe^Ww{YPMzZ9mRdF8 zNQ-~>DsX&^eT1xy->V8=>YVpbJ1LUpOg>9clC}K8($yo~jN(F-IaHTl+^SJnj{QUG z{BbOMe)=MW&W$uK-hY(YI%7l>h>e27_un0Oh!H$gJSVA^d#foX{IOalvFZBq>aQB@ z1?iHxLaC(4R=T7Rnr5+2rLHd`9P_AfIYS`rT-bg_D!A8gd7LLR=h$A_7uG+y-FY8~ ze?B*=qPt6!({)EVQ{|8#0onTZhP*q+c)NyhaZeG$Ii~^lg^L1y?Q$=w2c8CGI3pUN zuC@#$gVv^)u|rV8{()q4>Hm%~Tkll9qiQ@kH7X`UzuohPGyKmPzU1(^Ft!!)_Z?Iz z!5YtfuC~?qFtKD)1w8xbOee4tasL1wNh`5-!KwPnTgID5$rP$6wxFx+?_Tck9XS)R zGZo%AkH~_g5_^?D+kc&cwm5Sw~*di9}b9bXVVe=NVq<_GdOxV zL0xcUNhBci<>~Ep0sE^Sjm?qt7P#@osgp9B0)0Y*QkT}=(5_wU!?WCxH+^KNek4ah z1Uj2|AGv4hzU!|77G>HOY5Y%0UGnUzNKC#pf*JM_Z1*#?;~eg4q^9b)`}!bX;M7_S zIa=Z1>T7r&15^^_RjvDIp2={V*{9kFZ+3Y-=ixn_L(IK7dVlUACrx*>v-;+guvhT@ z7MT;R4y1+3qpv=0$IIwdLBqEg5dqJr@|jQ2u-AfKDdT1KRkj~dlcOJ1nDxTTnTGpM z)8)0_Dj)wbXszC;$()f9F~rBQY#t-_k3WNJ(a+h>A~j`Or^~*a4zcB@!BmCZOdC@o z7(S(!SG}E8iSg)tSHR_OnmXuu`odr~eVcm=$|`oU)oU)VsPlz_=7JH0z?liWnl^~l zC8Q^Z-!uUaNXl9YtJEXh-aM=1t;O<$jfb$oJA*?94DR#mk7a)_W9 z>uC#%vCZPY4Z)!-(vRs5YF@WINNE!NE(!$hqw-CS8S{~4d`TSil@?->fK^u`cY@m4 zigVw*y|n&}R=|&APTc3U&ZXZie<%UcL73(fk+yp@de&y=eaClVjuz)zD7qV^anFfU zNs7-kUk)s8_F@ZoKIFQ2<#I#8f~?D*Nz-5!>5QF`b14gdSrPR&?CA4Sp{|3y`Kfk* zd%^D|O$UKAzJauD{^-a6m%PRp7p1l;?v|@r5|GO61}@xwW|B(Z{_8Yb+TO&_ZJHh4 z@GR+==q4e)8(&6p!pdD}OI==`fJ-}rocQWLerpzM-u{ZWlfw1Z$v*n6Pa-&lyZKj&E@FP@jJ)9KK3;M>)(Dj&H z`sjnI*_RlY_o)Wuz$C)|;Q_l@y=sSK-v#jpT+H8S)277lb>pu0yHrDRXnwMP#s8o# zef=!lp&bK%xgyM%6{9X()DYH^G|^nhIdig_iiP!(wa-C~&2EXEpE8?M^D2LucOIJ3 z=cu?oJ@gyVr%;oPdTC?CnQUuml) zkGbg|N$;&CtDb&vV?F4m!(0va!?w zOHYIoC{Kq@Ap7Q&>&N-#r{kn;A&(nxWs6=_^C=M1zcBD}+;+ImwE1O7;gF(;f)wxA zlE%$K*wWx7;gJ;XHY8W;c)oqS-20Q6trRmkP!gJoFC(EO;iNQ^?+pw|_c=8_8IepB z)uNdsf~+S&Uh%VG9DTEP;;8&lC5jz2ILWXLcS-oneK*pA7kW5bRjWH1`h^0or~G9y z1YGXmk9hN`J)a7CjVFP`G{_FDwjPwp6wd)-l7J7}Vp}ztu?%@hN@G0l{_~ZxHonaI zi*>~1{p>gX88&UCy?Mi@ZlEp3;W-zi=^MRNHqyE^F0{-n#@}5JR0K;TWlAYC;d;1s z-?B|EJ>Mxq8S3dEf#&jY(c_XDv44)Mx&RBBA85SvT6v+c_k;1%P1X$C`djdVJDR_w zI{vyP^TO-n)~g3vqN)^NgtVOSvnEn(vD@ElRNl4TACr%0Y+2A6KeaH@DzFJFEdB;8 zY&EXD+g^NE#=Jh*AJ85xcopoZsEJg{RM*5Fou!Hr5vBq+jI-pX+@&tng@0S+x37)+ zMVG{3N`ftz(y8zkSIS-2hh;XDHeb$Ug*PZoIlKps$n%3cY#{xqc=1?969fO`MZDiQ zJ8b?bcC@v7+Zg|QP-2yDT%NPS!TZw+4Sloa%|pEBsluP%(84Ea=7-r5JD0`|CEdRz z{L$g^flMM*rerieVMtiPIBm)HNO+qlTQ2YM?ZV@4SezDl9dd^hq&huo?AcD^wfORS z*RqG$rnHa8CUf}VX0B{?^Uj|0Gj48Q)E7#hDm>vWy1#VW2Jm8#P+E2+N`teJUGfc zzcb*vDZ&$A1r6Ee7w^+c#q(=THvUsP+ih-}r{_{uM+JoZH@>LCrYob^iKXYcPz}pf ze%r!=f^SCgN%zGLVRt@nj5cG!ww_hmmA7KFV@DXuT_aUbAtwHJ{?h7Yb_t&40fH$# zsmq3;#=gE@a#g~YzuR3oe4%GbwwvPWyLa@#+(U)ik84)AejQo$SmhP*k?44_vGA^M z<(4B&sRh;O)!t!dw}jNKDEa2-)!K36bWnBYB`+)=Q%u~?>8cdvo%pH9N4DoHlzl9l za=G$8rjm(jY^r5L8!NwLy%)rxL<`eJCs!Yt7b^4u-?>kF9tA&iSAuzYly_~Ii_!u^ zb2fSGPVg+0j#HOZeID*mV*H~4RgO0NR2JB1lG)0a*#f7j!kzSf)@x}NzYqI=(^YMI z^Bu{!RC_ykRyWw1*Xg<*ohdUDPP$)(33zD#Vj1gp>%&9UjROIL6R{co>s$v3jl7Ck7_H(DslAi{5&MWD2{M9|~+AxF#0#4I|CH zU8}zNdj3v4HFE&QUad{|d%)&+c);)Q#?&*gnAeDKot}#Mf|>SpCkQms_Mf)X=XsiK z^7#F~hVFQr+AlUjQut@@*7uy4XH9l5Z%Vf@mUHB$`NxzdQFY?lD3edFE7x>0rahSA z1W)=agk_vdMcn4Ow2*VA<s?ZOw!=^Fmc zmErgEUUjq(E`QoScKPaq z?&rK<)H%@ji*0*kxd%VmWfO`(s*U?(8x=q8`!g*6+@P2GS=1|_L<+p|<| z^@}@skDBb(*yjXCX?ihDhr7Mcy}Wn>?y=GzJF>%A+T?Vo`{8*C+zCJJX>Dj~f6fK$ zS|Y8T^OZ_(_q`C7x-=o(lqei@o26q>Z$P@Bk%9CwXdN!ANMYX8a=zmV|M@7wmq{B-&f6p{n3lU&!lMjO8 zA0KFbb~s3gU!nZs`p9$Du1K;f(PcO=fQbkzZYJL3kQ)a`wopjtBo8WWmXpOW0oNmU zM!sAaS+72B^ZSX+)sua!QQ}_ixY>ljtYnLsA&Q<+I@etKVdeZ?OE^^+iq&ZkSEEFx5mq;+d5R&==m*6641#O=}BWKyJxRUbtG!< zz@CSs^~X9w+^|T1$bns_G35d8cgRD?E@Vbns!khh5uU!Q!mFaORpZgJ}eY; zVbSNHlO7mFWaL#m`lwGaM2_yB z^SS&K%<&I+FCO(-6D#VPOKMxDR2j9*aSx4Tm)u0kqXz#JXbO*SpP$d3lCp($*MB|o z!gFaTri_KA-WORcua7+;o6}uVkhxfMb6hWaHh@Vt_$Tx9BV zo@K+G$}$D}_5;mq&pcW-Csnt-slwl6lY;|Ryk9OfUblbWCXlp>WtY?u9V-_XzWP@3 z<^iaC=05 zXSQSec|AOO_p9Vf3%n^88P)GW@FiybE` z=z$!gf80#FB!M5Y+k=B$j}i<^V(jU*hdmV zV+H{ZF$05nNfgtx4w8b5mQ29f^Do}xEqu)KHx1@*dK0-*Oss%Opo0-fQO!d2$0mDo zdEUqRT4bEw{1grm<-mDR#OCFq&G)U)(akUOTgf(2-+ED?YZqKglUaJf3A1Z7e{7`czb-#R#+{cQ}RZ5iXS zSuftcDd@|Aj|iJq3i8sVz~=-{*av1+Qc~KuPG7y1eWqd%pr^nzM$q`I32o6#w}H;* zW3KKk3FEVtZ1jIY<|szdruezj|8gC35qsyRty%gSxxJc`MHS8^$of|o9K@-1{99%x z18(g+1rg{4BrY+=x7m%%2X)!L^Sl+SR;P2J2U$PJG>-Ny1uhXK!l1{GXxm1Cov#uW zT#F7eAP&>E8butvM$saZv}7$U$~D-NHeZsDGQT~u{isb!+Yjje@hd#DD}{Q&^1wCA zbt-;IbZv_*cvWq*HH-79xF%E)+143u6(<5u0l{3$>#5_d)-1^<8Sltj2lq;aC}Ei( zoe=E@MJgD;^OwjC^MATYYt1EATeD}-3K?)_O#xH8S_UYIiS6BX!#y6(cu!0n)7f;S zcX;Xj{^9|KtlUN^3q_S}46RdH^jLv<(K~UhH}qh8xapB>fBI?YJX6iPpagi-Buls6 z_IIR_(vFy=f(zAjrzGeVF6fvzliR7^Ql8hZxV%&T`)xWiz#k;jOO+SFeRbn-aYiHa zM*`dTP}{6r+l1UPeI3<5tBtl#4m8_5^PhgW^9Laps2w7lZv`G{u$A1U<2@@JBo%Pk zKI)wmx!lUx{ovy0o6BE9R4zTxQ>9?*b*;{V3BM@?qX+f`txDEPzs14|q~IAPf%q4m?fV})@A z_Ra)zNW{vp<$9!7(9)=U2804_gwV*sM&|mR5iBJC{bT;jo6MYbd2=*X+EkJ+k9JO- z9!_NaRrY4}8_&+SE9XBRdAhwQVF`0W_F+3q6wd6@q!#i|(=vil86gs$7LdKKEe~sK zwzzE{+b<9#c470u{w}$oZ!E<^RDr~|7&1+e>T%hSP|Wboj!eULV!+y_%$?<%I~_Y4 z)8D-t=U&S`!*pav_MRPr|45HmnG#uf0D2}EU zfy%7M^KSQgk8nVwjl_Q&Mwot#UQ88Hu#SnCgt}6>8;A#*mX)q_msPBc~EZof8nlR$yuE!J+J23`5wHVgvS$XIH+N z2z^eTAr#3KUUL{oq>C?&AQ)ptCO>I67=g7`M(r;{iFuy-E6|#GHyvfFeVOiQj5AFr zV(quoyetOQrmg{22D(SJoi5)J*Q6baK&W@~92LwdG8W^FbBpqOO-)SV07!l>?vd|v z_LQ1^;CSdy%?w>{{4gpGsyPDy7=RNb1j5;ZOii8T)WfKCl$WtzfxBdus7 zGKbMkLNJszvAz1%PamGUrT9V3lh;(t3?%jiq0)gxLo6Q-F4*-7Nh`OcTL1N#J`cVU zx`k>vq9|#XEJrlFFoRjExv~3hKKBwkA=NO~P||%{@h87&iGKFB<}$lV0AndQgfMz# zASIjobmUH&dxKL%elNx$hpUM7zKA=&%I7K(8z>8y-^E~vp?)LYOt!7KG<{Y=0$Kvn zQK$p(Q&Ae=z!gOb6{hqKpH0C6>#imS=bvfX6D*Ek!%9<6+Jh8j>-Wl4AJUWc3Pe!> z>O4p19ahVbG^2jhR5Ld&zm)w_%1mUDX~`s>_}@Yb7QXy4Md7|4Z?zi7pVn%K48_Pq_rA5+Yi2Bxd z?WPg1%?*Q0<@KJG9G1X8=(0pJ5D4DK*^2ZhO$@mng%Nw=(;%Z$t)a$r_Q9d-*`KIP zz=!uDcMqOL7W3+!++kh)@gE|riRr)IRaShLPSEPyeEd{V*TFcbfuSNSOBI)nm^C82 zaL0v=tVi(Iwidd1y;AUIM8E2k^x)nR@HSJE`lp+D(1B$7+%NrITChk2qGtDz{;DbU z@yrWNh2c1~4`j&;=sUN&3LH%Fu)DFlHT+$rNMmFhfL&ozIK(I$L(g~k2?9`K(Q{#z zseWYZuyj28SF|KYv`9wNUaLq7nxAMtGUqMJJa^}XsPulGUE72d1&6Nx^cYw>o;4ckc{D5y99+zY4 zeDde8-@}W2gr4w(}xzgt4< z=v{w|f4S5J-!@KUJk5?EO57H19X}x^b?F8Z!L5!i-hW?+g?mued|svcSmB(IZgAr6 zk|C}Xd>`^8oQ<&5-i}0%DWkR&vO-WYONG%&v51Kk8#Y_0TJQmQ$b+jcAmh`-)^FzM zoS|ID2r5=|B2n@C{~%q|W(l9l==PvIJ?m^>rSaIL#Zne;JxXk^s*V!E+@-q;x(APnEh1f zf8(_0%ZM-&S#?v$VP?)3JVOYWvh4Ka%mq0Vo@rTJeMay#5H1h6fqD6eT?)>#(t$E5 zsQI5uYPM5SLR_X%0W+B8voSpY4Suo zM!j2b1=-J<=C7B*{F*V}06Yt>lxS{IB92K{Qj3XDT;QX<&Le4C;FbtrB`&k~u{=PR z1iLB#)ZlZp8*o^#AS1<{(t$YHo2w7-$2`q?Hg;CjC>ZkKm~Vu94Zjgo;Jb5zFo-UN z-q=3yb)u4__6CQ4OrQ}kqxojVU^w90CoY?7_RR#^FK(kxq)Ykmz@SZg8*g!C5MlzB z$I~-iSC2L7&?)skRrq3})tdGf`ZMIVnNS3t;30kp0JLltKMmLt8(n`=e*r2`V)IWv z`4^MXFgItewJ*U;7SDwI6*a`j9Q8vdbC-F-<_A_>a`mJ&fg38&6I=0_wfd1#>Rs3c z?p!BxO*;L`hmT)TaCR)k@~YrMi%eno_P9f_wkPli$otBTJJxvN8nlS zZcxmLh7cFUQaDbUz|CI`NDT)!UUqq&rAUR9n1lbRyGg>`o^oBrF7==E64gBs9L^QI zknh1Tf7dYhnt~(;?9PIK<|TXL)P-f2gc7QMHbtR$6Zkx$SG&~SlL$_Srs(2~wPguG zbhpn9EW#|~GFeMyhXG%l~7#u=$>0@0(l1`O> zx%yFVi3g_+JxN%efvG*)yf?g0txi9OkH)YJvd=&R`~?D)vcMrWVpbsvzT*!$g{mPw zc-pWz0TO>*r{Xsqh1cgLVMLWt=cS~f69L}`fbE^QIQi!>P>#tBpK{6}5dMW65=;Q{ zqI>sLb(+^t4BslRCyoCKRysYV;xK0-q0rA_;zCtJqYvHx6@p1hoIwnbD8QIQ3%a{# zEXqF@7Kb`GGD9gTeXIvmdn}X&p(@L0_&Nw({Cc;EuG4W`aGuE`aToZAdfkDb7`!?- z1IW%f_8%C85A#XAhUTW#3L$5o$x{#Z6%C3PU!Oh3eGDMfk8C~qj;`c`U|Wt8o9?TI z{V;8@4cwi+Uv+Q=ny~If=rfly*bIM56Akk*wE}ej*1f_;E_+T-Ga=uOm3wyjClUyq z2CaxL8j*q=E#Gi=X>_%tJ94IQE+i2x z&4`#aG14c5!!PUpVMSP0v>I!oD_m`$VLycuYFJ+p+7PHv1l5KIjNtCbrQ|XQ+brrik;l+|y$dKD#XgTe zzI`b>zxs&+m^z1SNbkD%R-U*XD+!HuTgmiwoBwIGh0M~pD!Q{|Dk3x5tJ_E>CzL&p zh7qq0iW`GyfYCc)jEaXgh)nO7bK{K2ZJqjz!(n+^&{5&nosa)`VA+n!ZCnu~j$Ft~ zuUEKmt;UiR*d7@aZ$@Oo3Z(S1&U-Tvc=lkwlnsja*=PXv0na^15_)|`o_hh1fHa^- znFq}U$Tke3#-#6XK<5d2gTOL$b`ybbY4o9_KYzt;-WEE_whXZ(?fmd=vKJt<5cwL| z!NyqZ3bH2Zx!}@`P(_g+oLg*|OTULho=`8e8$=Bk_-^rLV7Q17_ua!H93Qu_n(_cz#fC9~BKZC>KRhj{S0)m>SyA04^pgRfrYX?Nr4AtJgf(;( z!z%++OHyv*39|wdziSJ9o`Ra$C#1UoAky54F$atLr`=quqR8eo-i+<=6UdIiCMzZ| z)8IsrgCw*$lev9+L;?<8)VPXU!`|rUFZ6s~dy1I?e%4zt=go=>dDt1xT&Ubn@3kdm zmUJ?6RsyOm1OYi&AlI1?>Icj;!N#-CvYpkmm=p)+>S50rc!;piZ!xgI?CUB;X7D;MmXUD-y_Ta!H&Kx#=-s=NQLwF)CK~a`C zNvqPfJ2r*lGdLf4)8h%_qMEx#oAwsmDbIt%;{oV=7EZ~JuKzF)Vd3X86@viA0P@NHgk)&k2044Tb&*)S$DT zL0LAihcp!m&mm{X$bhj-n+zZ#GNh#5scihX0}=U?R7^Wl2PXr%a0q%Rdo@NV%rQv;H zvy5ETuK^__G|c+sd%!WP`@zg5_isEYZYUQ*vrka=^7B2;`jaMr4TL}p8~gOYhMKVo z^-d7iu$9iFWTU^Z(-(m&y{i<>KH@11O452~b3`%NQLaBFsbElrvzv;t4BnLT0wD?C z2kSDzeej6s(7q%Nu~;{57Q>@LvYshc`v4HJjxqI%hqCC&T#r{}T@YDkoH)=*8Pq^Q z$KV?Q+Npz_=o$gvCMS#2uwe>QcC9V->92yCyqxyMh8lXXAhg|uWDK;ECTi5dt{t9v zD^2pQJu;vjV1D0w7fAvB2!-YtLSC-GINM=R4rwG`JeVY@PeH)T)Y}z^r8&PTgyN|; z`nV{Bf zUHP9+>6$6K>3nws%LKipB|;r)Pz@0+Q3=@I%!2ou3!Ip{K&=AG_-=RsBuIMfXrVH| zNjNrU=k=}enTl0kqbt?V`ipR{XINH&n`#!9j}!b>3U@dli$I~o8s3ikkRO6M==660+A0t)D1XAS)IrCfLQC2W&_dDx8I+D`}G7u>r@8 z{mw~)n6_iB-Oi;7$I-ik^ZB1t7nlmD#%FpXWBnhuI0(xX(sNq>9hNG12%ItF(YqNQ zAJ*?|)?rx1Mn((a)d=iGgk{FzD{}UseI+Ks4?ypMvcqh~dnl!)7y=;}t=EMl02OSv zR}_Y6|9F@vOxJ{hD>eM+<^2%D&iLyal^ZkUR)92~pZw-4yz%?vFiwE?)s8bPWE-N5pl z-q~DYIlb_|AFx$vWv(oW<$)`e)>AsPEw_#FB8 z_7Z(VjW@!VANrWq0+Gx@G=-Ct1{S&8M8IQ4|LHwvSIa|T`#`H}DzJ1X4=4OrfcaN+ zItN|o>W_L{cUU$pV1j}NxJ{}+4f-E=c0zZi%M@|VTWC7^+SkzifA@Y~82j58I|QFd zD9S}SMIF5D+heU>YM?}mx(((Ie4*Ham2O6v`#9cNm z0ttl#H-H4Dzsc-WZydW=BR`g*zrbNz*HZQYxV;2X+k)Z(93?~%xSW>AeRy`W&7N|; z?6?ptF4UY-ylYa6eHkUM14vP!b+#0(y3lxB$zaF_HC}pTtTx}v3|KBJ420aFbzyU-rC;1oz18fqhz3MeqpzgjaOH)MrLnH`d=lRP?Z za&ti2NrTDMr2gxt!ynZORY@ik^chWRHJyhlz3oFLjBJ?w;q`I%9Drvw6vkdm^R-J$FYEEpi^eo5+i2TF z#z%h5uT+EFdEy}e?gj=296Btp@5so(?8d0)yE0?g`wgc8fFe3N&_h8FhVSd4iv^|% zS{5Ql9WK4_fYWuJKEd+0t_~l@#CF%%=cl}!tQGoXKL`4fC7&!v@DRVias&HMB^f$< zw-e$MQYP>@Z1ag=U#$88APGnPC_;Ps-^{|UmB4u(uQ`R3{F9WHt>lrbl(mny5$*J= z4m4z7-q1)e+3F|X08`^m;pj9qdTF$-x64;g1}5mOn7l9Roh-0{z5__lUC)lkaq40T zM%>T}15QWdCNWesdTHiCRtXQ)(XWrX@j6DOa3XwRux}A`eK6AkRo4s|0Sm*wWJK2* z3hf@j0a2Ht94Oj{3cv>-HW9-#(w+)c`0CX$*BE)&F(au9@Xf&|wGt+kP{O2N(E@%J z`d!<+0jF~(#mam46?Pd9AXFBJbSi|qX#4jhWAd{DQ6L|hFa>4m7x+%s`h(%i`2T*o z9R@1k0;*yyHG=;lH+I`R=|V*vbQ}Qd{roqP&WNGEuw?m+%R^(-qX2Ld)msTKu*_Cd z-knT<-Ws}DlOmY^*oWW={$VZu1QRNe#6Fr+XnSD`*=4NuL`i9?o=X8UJgl<~ZLxW? zV(_bi{^k7W?ru8@Al4vVXk-c~6)hb!0rdt^N!UTYVO&9_FcNW#;YZ({Gk%d!j`uqY z8V1X{luxh+2Nt^cvkg#xZ5?T<;?vL=O}#RNqkePxe6Ea!K^K}o+nouPNLo9~w0o`- zObF2Xg!k#8UV;adqzGTwt!z(0)N^1yRvE7h;Q-y~?So33!)MZgj+P=WGqn~W*(%C0 z9=x-X62km}DMyzD5=(c30lqbS8L;v4I2XthTvZaMG6<-+^%-iP#YMxvnKVUI7Y5sy z-3?)in(ePzX@M~bj}#IV4WbdN2F*7#+X!3(E&r-*GEj;O`r=UMyxh62zry*RL=Vf2VjC25ncX{3 zv@i6X^qGYu!qh5+6uOessasI(0fUOuL%U39#rO0dzCHi;*L2@ZLwNZ1FZFh@3@K)+8#UIK>PS1IaBF364 zg#!&SW+D+n3z0B5F)dSl3J=WONh6H}2p6Cn$U|rTkn>ek8~GXi^eV-b`_ik(^8 zld*?2zrY{FGX(>00w#OVE8Z0)k=6}e*;pR5x=Cwr!tp-~(atP1H+P8-K=HuX6zv^8 zQB3|i3XSt%a8CjBiny6{kN_MyQ@$iVDo~fKo-S9Yc1tz=hRQ#w{#Jol80ynD>d?Ft ztfT-+6sG;Mv>l)!ga;o;z(_NuUM2j9Q#5AicVBmoiSa!8QTM&MhD&jD#ifKtV9YML zTQaafp)l_{xua$43iJZskZ^J~ZMtOgvUgR;cSv*254B*O*POy=z!Az(#=3Sd-_i`b?X#VDKM4Ht%M28e@UyL*8zn` zXUI1$=0wi;G!=cVV~c|cot;^#akNwe&G0C=c6;ERbqcXZm_The(CIHM(9G_Q-+L0O z7ruz zG-7z?DmVvH!~!*8+2z~DErsCsAI`{gWw$yYbr`LDjY^ZHBIXcPaAMz!AJZV^q6Eu} zAzt@3OhqlE-nYKM*~EfeBWRITgONguaPNPgW`Uz%^{WC?eE6^(fs2u4(42v4Rkk_iz{G35|@wWsKuXs5}5j`;UqERRh&EVzzVer%A9n z(3(ZMzhj0ytr1nbh z^sV&Y{3>3&#UFPK29}w0MxL4 z3c!^7Rr(1qai)k^{N+sV@a4KeBalBxP}!SX3~?*^!FT|_9)=`khzst%4|EQWiX*_B zK}1`nIdtQ44*<>YArc0CDl|5LJgN?*MD>*eS&6U>EZs!;>7N0-b1?7jKp_xlkYfdp z0#X!G$kDZ$xb8;-RqH9E5**$WKX_BROS0DchrT^+*j`C|xqP)A2Sv-R<@uTKJe9&y zRie=PJ4U-`H0+My|Iar^VmAN*#$q&1X9_>JHw2`cBMBh;Nx|q=+D`8K_I^ySO+G9Rswq zX2ABpUN%9eSX2VaHFPerOX+zI7I8p(LaG7YZ3B9z0FJV3NWo*ZYA%RaIQ{`^6w`YS zIL@pLK#P0-qDm;GeRmF~O&hQy|6L7xgY7T~x8 zT!HBuD|&vp5|mxlx^UB^Vf0{SP?!h&Z-|9fkt z?U6Gx=QGu~GmH%Hcwd}9aqSTz`yVrVOL$zr9i|KtNNxi)^sO+MhHiMd-Gj7Iv2gUC z9cM&la}M#HxP~V3@^G;*ecRgGla@s!3q_g2d|D&~gBl@plLHotLswF&p!dXVS`>E? zq}RLyFkI;#_OKTiL(5>l|7C*8yRZM-$pSnDG#Od^FnT~hT0da;D{>^HHPEj~pT+5+ zX$T3P0PY>pa%uqB8J^bCP(Wzo33GubhQ``Bq_+*^;VV*#pxJ({ zKcl$SWVte#xk>dj3O>u*1r62;H$rSt2EiQA3CtfIl;P!b|v@PIx0HH0^o&Y_qYfoC72uogo(y_ zc1JKGW^o0Uh|0n!kH~%aA4w6aa25^up;nI3rylPjBoU~`d=kar8|B45f*{XfRAGcD zgMXn9&RV}w1QQfVrckqHe#lW39_=0MF6f70K{{d~s3j;cYIYW`2~2q|0*L2opxdU< zeRc?oxzeIj zdb|Du$an|eU&qWjMyM}z1Z>Xxm;-9!51((jx16vC?Oul|nf)NeDfGjz&#yZE6W0%c zrYtEd!fijd99;=n4uS&+yMin>0ON5vCcB88&fBXhWOC|!W@L;f48tFVB^v)lrkO-+ zqcF4&pv8{F+K?s>Wf#Gqg$ng41X*cTLUt#lBsS61)CdRU z_m1&8*0#TUVPI+~M+l<^ve>Q|Rg2ppcIZ%)^*|(nA?5e_8%*L znFGv^B|xc?Vp~Rf_4BjX7{YxL2o(xsoD|Fzppph}&)npR@Po~Gn*Q^k{J>|aZ2Kv! z`+Ww`1WZWb^_)p}3FY*txz@oK6XzwTgwn#RoItX%47D4sEieG){bsF=C?e$GCP2Qy{w()I}^nU zkH;|pa~a)8AfU$wDV3@@aiA*VgsAH*xlA-s_SMSdLpsd6!*feo{Cv~K|Luj*KffE7 zioykMEwiX@Ii;Q12hqEm@~L3npuHGw^uXjXj0&lnqRIyt3&21$-0Xo(8;&h#nk8yN z5e?G5+_2Vn9S+=(n7|9HFTkw9nHjh(z}>+7*o^>_@N7&@pk#sUL$Gn_X*vhP7wfb4 z6pP&9)>B@ zU6qjA2u^oO-+$L0N}%3DVnWj>Xng8SiWo-VWzd0&>}^P+jE1I&c$|MWc=|dI%!&gH z3EKVrXpJog7re;MvdTa4&X;lFkFL@iy}f z0%1{TXUHM*<<9oTEyIg}C)sxVItm!B6(FvwbzJX~`sibh^0#F-9nJd0Aw4kFYb{)0 zZPpR*R#Ztqv}#LN)+1KwZpGH}6QCC~>7o!pcN8Gq>|T7$b}}erm}abq?o5(=+5BV;uZ?~f54uaJVqEfJT&vCZ0a^lNjBxc}HbsLL>tIM~0t zC=zCn4njr$?9Fs%wj{oY?sNibVnck;bj(qAYVcloa_Gd@XBTTpoK5Mx-&2M6pq{k5 za_}TY%s0%%Z}HZ)=0bEJJJbZ2F+REMG&Xe}ijz}J3XGA?6mHe&?ZOo4ktw+7!dJtt zSlwbgzyz$TC}dZNQQFnGqHEtWzQV9x>OqW%N)ojv%YXzhF!&`U7j1Rhc< zzSNR=SY8E~cR+0b)5w(K140Jk0Cyt-bbm3y*PD51ScgVr8y z3G7w^5NkmG$qwhayKyRe+m^mdd=DN#5ey7+Rh@$2laGb?xSYz!unRw&2plJM7#F1k z6a`+||M8#0^Fps;RHY4VE3~d?XhN|y?mXH0qf+qq7?&|Cpk)3-Q1CDUm9YOGSMME9 z^&9{HXB>NEWn^WKaAb6hNcP?{yKL2w8OJ(SMkwUqWMwB56%meEW>!+-AY{amydA54 z*US6;`Fy^&-(M+m&Uszeb6n5s`Mf`z7ET_!=xhn}298E@s(<_}0cqvFLS|Lwb^RTE zTPg}QnHy)ykc>Y7o!dj9la{^J-mYx))k%!G&uUzK((HH4RgJ>59b?s{#$-NEx6bbg ziImfZO4mn!6Py)I8O+a(__`yL3XaX-NCr9-GRF&ZR_skX9J$i(23DQgCTI|8H9@U3 z7A>&?Kuv7MBTyn)=$r!+OR*(uTAKcnTcC*?YKMd58bPGrR))@wDF)VO)KRl!Ol?X_ zpfU&i(&y}sH1zUWuoUYASeE91}Mq4tw5cVs2yDJ!d zA`v9Fy~tt!)f7r!E0}%gAVH|325{e%E&VxgB$9BT;$Ed zCP(*4N#~uPZG6(dN*E(C<|NVCJuOIfq`kCoGt~w`7#xo@3@!jG1*LhQlnz?KXYT3` z1K*hpS_DcSfgG*Vh_W^V0fUK)OpX97G@D#>;(lx0Pz~7WBZ02pSV5l(bi-9C4zeu# z+tL~>_t%d3CHHDDpx%$prq~b}Ldp77MQzgsv*Ee&C!wl$!jUF4%yI3sQQTAWr)m1Q zgpHkl-7Rot1s%ZT-6N+hnoi(yggxi2pb|6vdbPXGM5m^aY(&07w^!K((6bDhtBUoV zz;VF&k!=O%;JNstn}V+--JiR+;eRS;db1DCwtD}P&|nxW<(O39H-w}JvW z9;hGfQ zD=HOD3C;qtsLr9eGw`@a_fSVb2?`rQ+`Vg%?N8CRo1R&7y!{AoCEp@ zF>iLDQDNt?8Rj=T>b2~09Xs?HFSO@FTir}N(+^CYd~6T_B5sO{uZ_YKQRh&_=!v%N zxy017#N2P7q#`L#RxqCV#pMZS;2w)Il z5eo*NipUkY6$bw8=?c;^^T;hNhQV2mklK+TQw9`w5 zTfKF+IO$%H4zV!wN@TNkS3djL)HGJxnFp{ph33D1<6cZv@?``Wq?#vDDtnsqwDTXk zU;1$SqfWGTU?Sc%Y-FP2JnE{vTv?Lfci)T7`cWPn<5wA`zjy>h?HG-3s-)|mql)s#WQ}|x% zGnX*Kd-CwIY#E~nH?OmBohcajUKWkozbLR+>xI0xW{y82`bytLg zZ4GU*yLdUl{43}6)Mni}f^?9gOG1b+0~&6^1QtU5CCFdrQzpi+%Aqa07g7R-${O*~ zd!woE-kC4H=7REuRl2|C_!_X||IkfOOse&}|RT93dB(>c#uQ<88g2X>E(| zJ)DUMzL@R(IUzLc@cj6^2GY(EDgA>yqAXm3j+I-HA>wslGUG!zf z_z<_;A0*6;;e+6AfK=x3qoWd^?M4J0C0By=mh?dS0NP?cyjoHxS9OLKP}LWST^NE^ zkh9A&cio-vwSOtse6eidgp6}MSqzYv6FJYtxfK0L#@S=#FAQ^&k@4{Gnt4A#qP4D^sk@MyKkh!c?XcX73EXEBgYnTnCGAM$K5q@EeqD@(_kJu;jkV&I zd7^M9XI_6$m!XRjblXlNH3sJ#mg=5l_(C}0*o(F7Ua?yD0r8y7!y=}#4=4Om(S+$_k)nS_ZzAZ^$KWnf7 zrPp2P@2Y5F#K=u?sjcgtd3k@F6P$7TjM4kY6a7XIfSPShnHWEW7uVpzITOo%u**;2 zH5emSIm80=(z0ALh}UPkJFCmf|LiXL zxHVOJ5hp9E9iu7#m{*yNTdtzmAtUoy&2F1l|?l%Db&=`gGiHT z@n6)jsX7^yCsEa77;!OKMcjh9MW(yRF)^>lGNfkg6diXC+eZ2Cd97kK=tXk=!U_W06x7MeOwLgc$< z&yMRghL{)EiqJ){g)F(08B7*OAv?W~Lt>y1O*kuE{|>&9I9?Q)9NMLgam1;pw$e7+ zAb1PjJ)rD<#Y~F@bjuL_W(GB{nC>ET4rl$r^6a~MY4ix*L(>F(fLdD7I3pKWbv%}I z!*lREhT7yYrze~cCO7m!bx0@q!z-Csxi*I1d=ET3j0`n1J#R4o!;2HW7RTTFyZCS6 zwRuZ0o4i(CJ{Rz90yO!7i(Xy@RbhcJ8@!d6_;I>H<2{4(!!%fOQ#cCNYLXF&yOBu& zqf7&LRKOrt1fss!CH%l<3{WLX|${;tV`J5(fLFcz@r6FsKPHvv?mkvWStFQjLB<(% zT-9%e?hlKMZp>V)-0=RGBD{xS@ZBp)8V1SF+tvd=zsic6qH~5PWeD%~J5h z8RiX@=eJ0P^QGJsz206bBgRJeKv&+K@$D}1d@kQ){4f7?sV+&ng(|jUdwUr%5{}D@ z{bs(0?-93d%X+sF=ju6vrl9OU5=4qocQAliv(5#Am{PaTo1aMTg(5Y|kA%*6dIyY2_`ky_(){_*53AR*2Wn8$i&RDbAf>V5?zCL^}S925*<2m#eoRX%Hk&! z7b^&oj;I=v#sK&}+xRPYK#{8sM0~vhV=rMI{r-8x%o;RhXHG7GHcVvyp7huz2pcz{ zuz`{dV3y6%gfh3w2+md9ZmM5UPv9;Bu?b)t%WmMGp2KQh(1{S4EG{F(4{L- zVmIKOwI#}p&uF_^Ft1tKNGvp{c$#IpkKkET+Qk#TBy_{=1_Btw2O&NdfFe$w>rZdYalak%)fD{r_e z;0KEc!zjBiER5={>ZzML9mhu-?Rmz9hs`%QvOJ2C<~&`Ge_&) z63KT$%KDhm$-s!q0-tGD(sJtAs1jv2IdLfDYOAyMIsicTK`5pd$e8 zGDGD8D0!9}H(c$+;v^3nFYhzDY)_{5^USjDgFl(&4m=b%*kcURf^1`7vM= ztQ->PSHIttWt@uX?hNt{`xNrUYQ;bSo|y|-mDLgK!hr@flu=q>D$h3LN14k z5*JVGC`%3s=^3bs-=blNiGx%QkmojKNgv2*wq_!UW*80Jt1hfY4lj(4yaGSig_8`~ z&TR5aydi=~dgLBzg7^K%i9-UZ)YN*A?gucc*?`ESSb@)kvz1|}8R8zHZ*=U-5}RxGLKXvl68_j%8*N}fVS#DDbr<84ZeW3yaCi7DU6DAXz1-4V$_7UU7QJFXpIzsJBOhcvBm>F_6ihFidk z^h>TwzU?+ty8_6um@94NyIQ4693w@}o3ZBm-_$T4XWrSY$tOkA8T1>a%yg#s>?@y% z7ATV-lAWu7&N(!njQ3w5^_r5uM(A&AHB-^Ag-U={dPLI5T z<0RW#!U}M#J`gJ-6wO2wWp2FOY}+pQ)Zlp+{{E~knZ4re2a~S!bg`t2LgB7-pRRfr z$9hkqGxNx~eqZIkuNQYAOLNO8n)Fu{?1J7EeO*PJOXT+SFZo&Z1Yn$6m%focm^&U% z-Cn4E^~u?5K4QLpJ!}2gu1)pG-fz2c6b!|NZihd6=8|T$HmE0q^>QWQ)WzX340q18 zUmH}(3NQB>ZjuGa_DZ;E^vC{y2w4t3OAQt|T!NnXmwfVijvOXO9?=C@T1KFO17|+w zsEn~&O%)-{K4y}mYdUblE4j0ShN_UYVktL0%=PA{j`X1inJL8_UX&8mKnorluWpkP zz^tlZ0S@yRIio@Ym>+uPnvHBWY^{2%ha0genTmK`8FNWH64`E}%7C>TUmxoU8rN1>q5sMh%x!{ zneTjML#wM%K==UFafSv^Fc7JZ>5m0(J!wjCi7Asl?8vHbj9M2f9px}JaoDljK$RXP ztPeWrJozn4FO^|SjH)HDH-8OxztQShAAFFFW`w6_QP7D!wTsQdv(rRr9i3lyCZ>Cc z!HkDeMn44`9!g(1v7Rb@Mu>Q- zN}zp~hTx<*G%Lw1WVV%R&Y8h<;CCX8)6Cj;Y5vzhl`NxBr4J3V~-K>)`;ZAbkmLi1odB4 zIY}%}8~e6MI>Ge4fjY&+(p{#DFq1A?s`eMMp?G_^SibLQ^66Ms8|aY z?ypH)ipP_*CiKvPP47--!7C8|R2a_;USFq9eM;H>8H;qF+~R8N!ncovH}gdw%MKUU zxh3pWv(g={q|&D%50AX1F^$Ef$(>-}Aoj8oW7#EzgU2h^1<*3eSQ>HdLp6I1&R&qj zxg-;5@-d^Gwqi?S-tIKt?vz)|?$kTVyM$vha8xO)+w8>NVKb)Vx44=3-jYRmIjCgu{>9<8$ogkV$VX$@nu2&6!MROC-ytzSVQVk5DItcZS{4KmB|21LP7ch?%( z;yFEWMZPPV=G-`8q{DlOW?d`^FN+5aHZNjh;z`Bj)bssTAA4qF8L9V}P~CWh&m&ct zAxn;=#rspgE=9kjk&fpiFkfk5QA__ic%8_WJkeDWL#n|N$dXuY7}FOp-p9Kd>}Iqx z_3ke}#p=V{H%OCvyFR>y@O_iW;y^RulMBh^g{u>fL=L_uecwKF5siPoAZo=_1;#rc zKIRB}*=JcNiF!bgVjBB1PHsW-SFQSoL$*1Hpk;TQ(9b4P>t$XtW>SLSyn3fGBe0<~cbRjuUB&I_&8V8;a!{Lubd4xLGE+&ZcFbR@S? zZ@{c`Vc#8=2fM1M2k?Bfh-cTG-QSwbgfA9gLDH8JIdP(Ay|QkMjj0QPrub)#XwkuC z0XrIOJp0+B>n?b{$dAIM?QOM+?dW>JA!CjQ(o86AJe8U%256;3+L457m${Kps+aO8 zMZy)g-zRA=HDy{i1R==&Og4*SIqOA{p5tJF20PNJu*N8^KQ!cp!pIQ3zB%^`2j>-2 z8(ab$_>C8AC<>jhd$f{q6I~i{q_}wl_~ixftGO|zJn31A{~+pVZ_uRkA}Wc)pO%v6 zM;hGOwuwpKI+lQ*@ISxK*`yVbgzw_BY=Rr7=iTh$ld3x!K!eEkmw@?KEK$cnkDgYL z6BN22_x3d>yQ{+#v=0}jxx-8nc+{%7j<4OcX}gHZF@LRp$W@t4t#)~OWPs9H;Bf+p zAK})JjEuAveNHLQgRdp2i*@h%Y5L5$`+qL;9#@2&MWlPR=wdkw*>jkVUp{T&ti9kQ z@Z-H%mnC>KUk)}}^oE)|P$CEZeEkpH>@VMreR5%9Npn!U1y)y89MvbYpo~aAAE$++ z<}Ps;xvB`c#`A=L<%#!BNLq5ivuUQ_4^45gr>XdVWZBZ<4umf9Xllb*u&esJWyyI2 z6%QVy$%03vUF;I!US}FUtMi>dg`_DvOj9ZRliaH`8BX5$7d!Ul`jP1NGFHS9Qt7SM zBrcCJN^5uo$^JV9uY)8v9-LfJjT^GjD`P!)=HS-q>?5X&y6pbryXY z`4WC5RxWEx{++!Mqa2!%QQvj{P+!M+ zAx1h%bI>+8)|h|@${wFoZJtc=PZ(f z?F4AV43X)At#R|Dj{U;tNPg4mk%q{ZRaE`)Q*zg{CUPxY7jWPEX<)E0FG>d4n9t69 z%`LdF=X+cEUSKfwz%MOsEGd_;&Q}@5$tK|o4iLAkEaqRf@E;L8x)3yd{j*+k4#bmR z=g)d2ey#j<;3~Fo;}hpd*u2}H@ScWo5y?-Bnq)iL$(5fJP#I_JwA!{#_5nW|h0`P@rMNqq;?H|+#`lr^wK#Gt8&hs9v z%E9G*_?>T-=T3dwdVhIRb+-sc!h>PDcfRKi$#{M=i5s{fWw&hj<(A`n0)(qGhgzt? zwaouLu#kfQB$2DiARuz_G> zF|_*h$xnu$?>9QB_9+ZC_Q^8}t+cMgJHy8fW0%4nQ!i~UMNZUuyX%-GHfE@+^x*-v07zc6`oNWG#j^YHbS&y9@Tx9|#A5#XD$*N3BKPrj`>b9^7>RArFp zwN*QrXDiyd9@Dhqauvn6^krs$S&zKB#l5ojlU2X@<9eocVF8=@x-!;7H72fs65x_X zhWMgaRgMJ++iw&EI2%Z!L}!ZL+woM8Q3%YIVy_3Gev)46hh51$M@wgZi`o-*WA>o zZM~|fDpGb;RS`OtAo)^7I&I~;!_vFQ-ED8wf?@mWIl$;k<^RtT)Z<=Epz(POi0%*<&6Z!)IOgJ z&k*nGBZ~5;SrBq-CI^#lG+U&WIC9=p`OSA(bYT{(x^P)64ZFZI{58z{pMSO+sZoWe z>DCi6lR*&g=3X{Vg??V0Fa!?g%jcyw=`?ZY<>v2yO?V=NXn0-i)?=*QQAy5xQHS$4 zxCz(K=tS)HCTe^8M4M{tJ!`y{1DX_`fj(FTmeZJyn`Sjh$QEcfMZ4V`5VOGxCAOn3 z>>9>yD@XNCT!wW>S66>btwoE7OwtPXgKilGo_5cBl6iC;*IOLvjrsLxuZ0eYx^Y8+ zcq`V+xSnc@w14N~D5+PUy0$6;y9$MBSJ4cxN3)+C)DwdFiB~UinLfUA_hruS>;T?w z^)Scm5`ppHq-;G)0Xw^)1(qM7#=_~v>=kI+GrRi7cTt(hLeb$Y5ZUVoZ}ci9=$gJD z?RWcyVM37x%!wan#uL6?Ao?RMjlx#rG+aPnhjWdnp~^o>mNJa=5CnZg66;CBwF&xt zOmqpHxW>*IFOG8@^y;OavB4tcoin)qH>TU2{lA#*7q!J0_@6I&w(RleCYvKwmK;?< z=jy-K0-XQO-=EA!|6JbB1eP*d^b(5WDYi<|FARvdfLY`m3`%sDME@l1MVrbjyg4%+ zkgQF$c$rC$+}D7-TKv1MY@ovmtWNUbgCg{Q#&V~Obtm%WwKz+i7o@sXo|JuVHfea= zWyNZ85pm!xY;R%|U0ROM8Sa+p8=x#bK+jtuj{Y~d3iLzBIX)SnI$Ts8 z9T5$LwW@?`6OZ@K3cHry(+RSL7fp>=5(3ICVg==2Y86*#p%3J&KsxL^w>WX*HbUwm z4~*&J;7G)RQ?np9j>c62^1JfwmXXgbDe8ruihF}Hwxaz_tfk!*;HpMI9xcehy~=sK z+c1vq@6l#M@rGFt$>z$RJZC@qldF#4HBiUa@Ahhk4RHkLauR5!NiR<3)Zhe*L;!Ia zfneB3<{L{_{r|;fha)={XH)!8nTl~yO1Cpf`z@q%!hi0|PD*B16qv_xg(oFZ0?JpU zP)+KQ$k(*xmIqwx!B2{5ia`^b0D}QWsxl$+oKw zr^uj@wDp7kjlMW9j{Y$-hWT|?NualDOE* z7$zIQI?J?@MXV>29}&68-~_@YPshV2)xWBXEpWr`Y9HJ>?j@hd+J~&OsqEg-ukRZn zA6LfLa;Vvg_pYXt2po$a{a$CG8@GU88M6GmYQ}Be+Q3R=QY%VY2==U^l781XQb{aj z0zo)Ruz-*GQy%1$(y1(WBrY%V0pb{U2+>g%eblA2+_3-bXV+8Fa{LzjvfI_STJX!< zNmG5c4K? zi4S4M=jfT>MNb-Q=1Jl23uF{8qN7-TfRIK1 zU-~y`lzik4WAJ8x#-fs3qE8)q@^5WpmR-&-J~1e2oOVkSdfV9u_sd;p^H_idRW9jG z!z-&PP7KG*;`Z6Je!l-r5NxIyfyxg!Wmh%oIC!>z)JkW-Y?W=Zlf@;T>|uR8QLZGpu4^Mi`KLRaXse?Z4-iFS6vY*RU+39L9ok_c$F} zY#$SXuDm!o&-I(0b{XS`6nSgzB&Accxhop}gIUr7cijK*oMI-cZUZZVn)ix0Y!9P? zb?E)Cg0%@Gf=x&6zX;bR=&uJscL^+KaQcX$NKU z=sTqWo}US3oG%Wq48D<`q5At}XX7o!_BFgJ^|S|;s+}T$dCdRs64+ZD zr)mElK;E$iXY)yvYhFB*2H5Uz5Un|#CtSZE+ua~ z6fD%r?{0R}o%*Cy zs~9Mqs^I&O}5jqba;P=gWd6sd27aD&@ zfTdEa>6IF{e9+Ew(?ZRMzkbvnaiVgz+1LnnV-Y`UrW2s`F2M6XAMU9eDMk+EV^;g| zno^YcdVmR(t7VYwvUZr&He{&{`CPd^E>Fdc!W20T|N2lCLB5eD7l@7AdLDiC(6?=~<9xp@vRtCrqfEUKeiX)YAyzfwRY*d&2c%_cBAs8Wg;HNe+#{ z4>;mdB}(=n*D~Cok=d8c0lJ2r5p}X)}wyXubv_vri>xcl;vNc>43T3 z9FnXr{52Q9~$`Jc28uiUY1H>ox!OvT#kcYO19uXCO6nr86eC62)L zzuoWd5wj#LC+Fecma*<_yx_T)sKH@?Ym$u%k+(^_1e@p!YPLiH17av6IVoqbqd~z` zP_I7`B`vP-of)E0o1t2X38b0cTTQQfQ{;M6g6$PAr3((D>T%4rwguvDbxL8e7P?Y1 z6qbc^#CgT#vl8)3trT}&J9I_w9a$`8IpMJ2z3p^6;u=s%YqCi>4XD!6P7?|t!;Y=I8l(7c+43gXXR$1Zk zqNsacFQ-@P7{oTlLHY86rIrSHV=t#Gr+6wtisIjz%j?13V104$8dnchC&Rf>z)h-( zrwG~%4$>MbF4{)#O%v${|Ji`zg^~tuAVL;_pt#NGQhTrXBf0 zM~+0GqM0$6Oa$x8DU@ZJAKl7~VqXvyTPj+}v?hE*%b@JqnZ*g<^$q(2X2+)l^@PVS z`>K41+4hq0M6JXX;nUxTgVd$G_~;QBYl$(GOGo?Q@o$i{lVV@Cy+l6n-(=hTZSy9J z_iPT4jhEXVw)7_JW~aO++C{TWg6^6k+8Lq3T{Ne?TZVL2#<=B&v^sV&6xE_-zbYMl zGuPx7Ac!N!Ekjg$h1Tj^jcrU*49(2qHfcIOGN^HSc8%T-QQ%fn%ofK;cqZ&b+V%P# z|0ezzq#{zE6TPA#Aq%a1bQl#jmqC&D4~m)_l_Z(~qEw!%NST1m;3GV*IJjQN(1~YE z;fEyNEy?fn{z(!tPP={sc}y-jdQ(hKXqL|xie&UBT6qrzY1W6x;^@REK zA)|2?F9xF2-mXsQ@ql^Mby<>Qnq0y2V{@$gCjYbUGpGcz+mG`>3D&D`gBqI`RQYq) zK!H5)wxn!J01fL;?Dl`PWq&7{|kEAGKmZ@6FK}zfVsX5ofuq5f{vz3P8BmWeT z=CmJB=HF31_9s29w|OQ6rkMID+`4_-Tb$-hWRo7~vJm`|1=3j%H@$z>j6bXeR($TQ zB|p)EC#8d`flBJ|L>OScUX-4D|IjvXjN|i^ap0TttAI5}{q4kyR@Wv>)d!n`2Q02v&Xp?@M|K8{zikManh>(mm#dhUC-b5vitvXhZn>ofAr9_>?lgNF<9< zMmDI%qH+4#@LS-hAPbzllu>dm<-|%>w{$>mK2HkNpVAX2!h@vI`H)rb2l|+AaJ6XF%Kvpx&^23<27l!_g2|;`O>sWi_ zO4=wJH7|)|@-{C1D@nEJGjJU2_bNd;V5nj3+FrWDY#B>BgiuPw_zaEx+&2Ua(i6o% z!sqGvYpUpoYZZo;y0U~<8G1~AT)^GkG()S({Vn(@4xwHooB4QhP zBW)YhCe2znSx3q4`W=~yx;yLh-KwjdJ(^i^7%EDad~cz-Y|`U@!tQ~P{5=Z>nhYC> zba%P3&D(ZgP(F|Wg=M~uPgm@Gk1fTXhKV+l@3nuKWip}N-^fvcO7{wFet+;G=<82! zB(yxVhVH1ouQDd<&BGC~>J>3LWv=R&G}D(1&DU~*RyE(7Rf1a^Rr?@vTQ6um5&e~a zeLBxNx)TT7UsWgHH*A%o+%qbaCS#pFDj%lI$l!~-PQ;kAXb79Q@8K{zP$kPKZLh3? zo|i=d>?U%*1i7U>@)4uAmXEm_J^tqDz`ss;t<33DK)ye5dHI~3^XvW}11sXi%fO4@ zS;$oz5#Q^P?{2d762^O9eb9N&iNn%gI@>h)Lx+8*c>)=g!V%oTi3XeT^8;@`eIV@3 z)dzUXq?yG~^AbEQ>`kqUW-@?A0VqKK?}`oAev)?A{b-lnS32w^B*LWa*2HU1qo~?L zh-v0Sruj73a|0&*X|CUa^TFcYnaxt{R`Tn;`Ba+UdM>NpyW=B`ui_o-{1NMfM~td1e(Q0#AivtORE1-|WP&>JAMKQo8Q>-ItO--_`$b zHNCEHCiZ#rHi42OYpJt9flfQ`|CZ}3O73NJ*!K)$Y{QR7;Kx&CW!MS&PmNH^qS2&{ z9+>kpGOV*Nv)WZlluIjWSZYyv)h*(WMxPdS)DqPezGe&<6Aczp%XzX~l+!Bis7gNo z&Cug?vC^!8V#T%eiOZ#q`HMUpncF%mnl6l}mt^?`@tUEDx3-_)?+)$O^^)JlItSf} zV})h^>?X$uWtBwGi*&R+va;aQV8=!%IY}$Z4Lu%+w4?tNza`E=U&I4bATd6sDvAz& z=?kVD11`R9cg){;RW@|iGIkgKQ~tOe!#JW=`#N;LQ`FG7^F4-XmT818GWzwg$}<{@ zec>-|tKFLPIlx$}!MYK4@-Bc?69=oN74GYp`|#u%?=01u4;ZV$M>@_p<@&n!%X;Ql zISDsq1*`h?!$@LTKsl^n3~otK7!j_@D@k0W>Ih0pvyU@n+AUd;cE2i(qf%&wd0wN> zk)yj72^v=P31{`iks(m+m~1GE%Le0~(R5K>Rk7KTZ7y{ErNo+| zygcPg^Uu<{z0>shn|t1M1Cf)$v=8sSxpi4GKgY}FeFh;?NIg%~geRKs@REL?+f{IB znl;%CCtW%Xt0F@Jc+ulIrw)OO47TcO?;MGY-4BQ{vujy?!YG&{t>;L2i*&&B1ETkM z+}Wvn%%4)&ROae<-^9iJm4IIw?0QeWc!0BaqfnX~c`SSHkT^dHdhxc4xW`n+`?%BlDPZvMqohi|(XV zaIwO+z=OlVQ|Q~7ri4lA2Zn~c_y&$7&L{L*s@4!+5v z0Hdzq+b+PBM=#aS2^*2~wsY+siecJo{@t=<=@ei)%rC7Ls&@E8@HL8^ojn6*_PbyCOhx22su9t8T#Z{vdANw={uQCWf901Jj<+PG zgq&&V!*V&i{Qzq{EN>&$Iu8Yyo=x!+`LPzvqaPUr0?(>c5v65|^ECjmf%-rN6!^Z_ zA6$c3`s6?f^!XIL` z!yk|3x$s?RCn=i9H?!^J=%`A!ZGLv!3Scp1#$FDHM0>vl!}{;*7o{gR^r7jg5&o@f z?`oRax5cj9Z#Apem`w<$7YL|lG`J$ptNHC2$n;Yzm>b5S!NPoIxm$Taj0gZb|H^{@ z3qIl5jFK(u%?63bG6C#!gQw~_HdU@?r3U7NN$-GnT|;d6$eU{=cR#|sUqi@2eTYbX zI`;jC-^ASG^fyXmVa)S-7$kUm7(&@nTc%BA{xpt*=UqVHeg5&QsJXcTAW7{AaRFWk z4S+Q#hYmZjTkNEBm<{uj-g#SSzMTG9qgTZ}>9rlChOxKn-PGFiuIj0l`*L^YT^kRe zbLe16H*;an;RkaZ_{q>@k|{cX=qN$+j9v1goHTc5nKD$>R_aUZt5Z9HS7FRj&uWiFVYN*_M#6FXsk8MaoK;fcf1fi%{}k8t0K@9A z<|qz&0TRh?FD1OezJ_q3FWjY@C7)H0H-(>V7?-Q5C3RF!V_ZDUwyO1HV=Qim=H9@= z1haCR{LH`jSgwt%HwUqE4RfX&)&MQAf-rUfSCR@Dnj{Z(6jgmSE+xSMo{5U{Qh362 z^PH9wfISTQXbgJRpW53dv{T6HEqdoQe3E+x7*#jk_R>N;YA_gyczz)COm-P?V786d zBs{G|NF;fzR#}5*{@vncsow(VeQnG+EizxuW!HfTXJ!DLyYm)MmwCKKlxwn)H*HA9 zFD$N#7kH$H2GGwru06KGgWm-D?K$$tnL|sQLz4$G?^@(tb}G7SyF*6TFWm9lKvY$M zJC}KmwykBnvTSjZ4iXfT&@+ah!WA1lRx(Us6R-3tPWdT-T5Di%58#XduMpyKnyMXq zk7HDGZ43TC9lx+0{1;x&bB`h|lZdfR&}0~I~6N0@OR z>3Gy_#Uz30`03VHDNF_UzE&Q=!r1hh%KUv_Pv!$>16Xz)T_)R$i;MbU1twz=K#~Sb zdkV=Z;D92+)wd~Ep?9|JG3C^*RqV-jE&Y^ihaf*@5r& z10XXrG6)a^na7j0wKQN+!Bn)$+wyZ@R*@F;&)CVbiU|W&1|S^)AdQt5-*!aJGb+ff z$z_UzRo)+Gn94VaaQx-`{4w}9JDywzc{JcH$=%F(RHd5vNM>x&eoew5v9{7t<+%)` zmvz&CD%9~!)zwmMdq5)*(lw@DjhH{0V<-IOTU}gq>ZFB*kJTWSn|?L%6E02XD7BXU z1BS^}lx53IW;P?hjnlrbS!0UUFH6hXW7iSof*ci(nuoxaL6hu4lcz@`os~Ek%tbu6 z)nGI6OUdTR2Xp9vgBb*IID>l|p{DZWb=&@(+6_x9RVpNXwYbfnp_=Id_FJ(=V9GTn z=UgS&QeO@unuh?w&}pFZd_Er_y_sY4RUpbSkQl^=_nH{H zR}R+nMM6IaBm*F>9>5)enbKuV;}6vk=afOB4gfjt3Ypkj59Ef$v>tw1{9Yzv1wMkG zo^h4Q$HPYP7uR5M2#-2Pk7dTv(rjq)OO-V?-c)!uK394w4Q9kX=I2tqh6#@>E*DMc&9hI_lUjxJ?%7ypbT80DR6R!f-=Y`Ll#HkY*=Nug!+bG)1+H7K* zv;+>SsZ~r;b6e`@>`i|HZ4~edD>uBwlG!R@l5I~RwW5#f63mk7W;X8HQeY2w#ZBbD zc2yKcPx{YPQ$y=w3$`&;Iq)|8kOv2li!NaLoqL@%wTDysO|Syb;P}ci;SjSIFPBq}!97BPw4%0i7kFN^r-(5OMo0ZWzXQ?lE>5|W7pVrg6W@M+h$!mW@ z>E-5*w6wG{Y)<^6q>c7}E8e;mE@EBb2{?U~hab!>OTm7Z6yGqO&dD|&?!m%jAnYg@ z>iy5DTQpATm!YjK1p?N=5VM)3yKALLz@6NPjkuOvFDJ$-Blli2y!f=Fm7uI#wzx3R zLAj46+S?z%%WRsQ0J=>t$`>@51fZo6`d87xJJZ|9(+ zt*WFHFeqjzkgjV0Pg(&8)@=MzAVWyVZ82#Qzw9LX#R_c37o#<)N6&#I0QGDr2-V1r zsnY-nTn&U5V6Xk168&d13j04FYPsI|*|RfiXLkZA>u1NP%Z0F?9A8+fPKFHx!x79< z3X+f{1@yXjhbB*fsVd0hWWMxGyk8_&^Y9GNcD%^|a43-RWOCmD2=S2e3kuuIv0HP! z3SoiefX9-Wfe*(w8Nrxwv+?WXQ>G&ygPf}E0rk@tD6K6SPiN+9T(Xo~u%9lj01H(x z6q!_O0TFsnyHU1<|t46U!C5^z}0?R^Gkvw0!8kcZ*{15qBOKM|On3H|i^3yY$FIn?%Ur~C|}J-C+JHYrTinl$`+QnKQ5vQgw73MB?91W%x(QrqQY{CUSo zMRMK;Walx!y(0U;-EL#v-Ji1?ngx%<0}QSM^$|Ab#&JMbIp=XI5y=hVLuuHopiCSI z7_vZuYP8T7WD5WoL4}&CGcfE@c(birI3wN5T_mz zeFNaY+Vy2yfTPHl)7;5quBis_EK(Z?NHv1JwIf{@YBy!up^O_~ujW^{pyd0n=%oYD z#Tv_(w0LpZ*5Une*56}M|DTX+V0V`Om?|QhHBic_E;-dP{Sa(bdvnQWf))nG(3znI z2n&HI4qg{>{!O06*^p}lCxRt0N%Ng-5P>K)KUto}z~093Q%*kiQ+&jU>w5`n$Wi)N zE5uo94`Of4Y>q#ERe@(bdY#2bNSx zms5Bbgk1p8sip>Sh)=AA-^PHEHTW6;Oj#{L3Tbr_;Is!p!WfY`aoTAYh~H_;0gMie8TEGP1d?6m4xZxu(y0#n>^uV63&}k z78B+Mz?FgRDR2NBDL_9#%=*u69bwiphmg5~5dV0Bw8S9U^>&?E9<&J|;$Y2oIuz6J z$|c!743)jNH2mtokqSN8mO5!`e7e-YjcH}~j&e@X2>|a&$p%m$Rss@BUJc_pN)ZFI zkt1lD8mdX|9wVthukzk@C=}xX-tXjtXc3Blqx^4~=43v$Qh?T^1XOaYL*o)SQ{D$; zA4wiiM}=gUSb{lEVa{u(U_cD8K?qS}36Om%zn~Kt*oKNT)kS{5YXKf7B-9G1s9@s; zr?P{zH0yqmR6%tP$b_*k?bkd&ICxgkBVLnjw*ZrrnhlcjY)t7VY>k6ModzH-L6L#7 zyau#1?1^ZygL8AO9a}t~x(2D^Xq5%X6n}%z*9v@Sn}8Y+bXtA`b~QLQqq%`6eV)E+ z*g+iJ+<>+o&zy{GM50 z-sX~%qiyzcfw4n?l0K?ko=8$?|k=`^d&jt}&W+HajdA#^j&OoO9Pn{t~w+q)QO zTVpR-F^HQfCKEXUTDlI>>$0MBn5c$9l5ODgv^)c(aFAO82`g&5HIf{CC~lvD0w~0# zI9<=mOjG_!sfrU_~-k1pdIAISUX_T?oI1yxya_z zuc~k=7KAMwkif#1RvDbv-d?(yG$}qe4alubwfUzYfQ^Wqc2H;nTqte7c7b3NV9|nP zy{f6&H*xn_$DyD9I#``KSeyG!X>3n7k!qZIq;3uOe*dZ4aXg=o z$F;BPe!psejfDQX9D3CJXaE1+$c3u$0fLi*<>qeDOO}+0iv}Z6=ggyKAT5T>;8XH7 z2^K+-f1S+xL0Cd)VvQg1qKHxrOsp^Ld%lA>BxIR;pAvnZ0(|Ptg9#a7cvZ-EzqXfI zZk2mS`+5F^|BW&)142CtGuW7GUZG76Mm#42Su#X1? zgQPfq>FkU$!*1i^{?iJq`nbASLl+S-QGwTIbUR~7`W+qgQZjOX7?&SCBI*3HtSMmm=8#-wTnx*Ad; zSD7HOq1-kwsRgZSFt(54jGQ?~?)}Q8gZ=^0~~4dKf6xfCYpWLOVs22zzo?#tUTm-V{^!J5AW{1=$y|)X{iV z`n2feLH6C6?1ndQ{QcJR7o`!RuFqfRv8$kot_GzcUZzsdYH(DmZ|o#SDr!VjM)AZ* z`6~QB(b+!Xq(r0gi_kx`SNfT|VT(vy5UhW}^nQQ>%*`rmV`S!nYBocachw5%Q6J=(wAv~iawWO?#XrdOXc&&yhhkw!>+ zj6kz(03oK63=&av-*gc+MiKuSdg16W^lx+M(rXo_=KYc*auxda58xVrybi)_KVvOo z2{sC4j|9Q-_1al8DUfs(@ogh+yePHv8IUGB<-U0Ke~&Y+i+JefSsMR;m$7*@#lBhU z*ZVUvzYdQ)wPb&{7|?#xKvX*p0#M=%avE}5CF_;s<(DNqtzQWv8k z^I>N5UPSZmEemDCDn&*T)r0Y)jU$f-f1fs6Z=byM20S04r+@(@u^7iS5jSI$NR9{s z54^@L*xIX&I7;v!Ou`A*{uKs7S4cL`iUQ^dN;Sa#H7>VB2~~;bUYuqa$~8V%0Dn$8 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

3HDY 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%0A|`5BjsUKjE5o{=LIwUfJcdsb2!ME&aM)nerWFK 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 z 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

AGU^W7&2Y;B^Is~ny4-orj&3(j8hfjVgE72Q+r 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

  • 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

    Tk{uks5Ib7(GUY)%l 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ⓈF92-4u|~~|AB`ibdg6N0Alk%0y9}703~Eof}eDA zd^JH;Ev$bXdF+A#;*xhQ5Vp((ttyamA@?63IaQjzCX8(JqO}tmVnwUa_>_LJ4R2TU~Lx$iTcgdbiUa|OH_lRlJqzo5q^zWqv%9w z{@6C;Lp&p;opEL5%R|he$g98UkYq&)n=dIoY5pZt5-vIq%Tq~k&I-~14{hsv4CWMo~|N!#|x(g_;&VD2te{M*?pQ3~S|`izCn-1OslMwQD2ijM9{JM8vjVJ>kfn zLyX)fX>P+yBts@lNQS-a7%KK`XRt1^BE^5q!3x8uHX3=rg&kJ z)bl1;h!2`m)0p{&8PubjNc7W3NxyEC z#3G)Du@r>~kAKgj>I`rfo`;pl!d)j6I++DsO1k0fO_)6p4%#IM+$%$d?q2sY1)c$H z9ApfsalDY$BMhSK0PLuLVrbZw!v`*)q)}&#Qn3Jb!IL$#IHW%6l&B|@ii1e>$PpS; zWET7|!W#SL{;jDl3d)YB`dA_2H&aigK<7#XlS!o-2zpNt z6oKAGQW7$@G=C+b#3%(6H8^8DS{LX7@GQ^~*#yQ0*e1a>C`kAkc>dQkOIHO3O8%6z zXYt5baIO+83kakZEJpG(Q2Qndhh1=p7$L&4Z@R+5Bt8EtAL=>B1BMjv#QY|cC!eJM(H#r?LAC%|H`d`{B*ZKRJ%z;9Xr z%a*Ala>x{!jgF*-=Y?o+bX*k{kgh6$%prIz`yS#LI}&YyGI6UF7Cd2G5rbz(khygk z`d0v|!G*y)eWdv+l^{?Olp$(V(xN97D()VsW82l=c+<8gV2T0eC#Xa%=*cTj z15A-gyz|U15+D`VLzbaa8^TDnBZrVn_&t(k60?}2?EHWC^+@86)xu3D=l4nA`g>6B z6b7(m?5|5e;cr+!)$sSA|Q*{<#D1QGhw(6z!z< zzX~u;UyK!x(B=GvSDXP+mudb_-T)>Cl$l4W$~2beQ8TtAeT1OM0(-!WI?X+pq{Vx9 zwnXqw31C!;fE80PS%q3=3OKuB%P_$chd44iB6LR2C32$YAap6D^dhC{XI}RlvKV=g z5@_ZE{8NA&%PWjm3A&cUS36gP$~~m52E@zj1rYs)HWxNBFkgVlH#rj z%G7QWIXWCKT&fqW{uDY7D#SBLNRf|`2LU>usSq$;QdDgij3ggoh^LhMz0sE}!C6`X2}Cr*<`*?wFc~`0%s0axu5ZIG zqFy*j@&usg2HDanN#7z3zNjQ93VkV1#sZQtv=bk|rWGw?$JG<+pr~Zg(@{6NP)|7{ zi608uo2H-bRd5tW`C87>PMAFvP}R{!${Is1#^#{Kphf4DB6xarP9M#M?x^OuIRO{(1VW$JJEGhmo!c0=^R@&oJ8OIfXOjpWu6IIG)$hD}BG#7s%c>|Obu+9rK` zm^^)?dh|%s+UD8hmXXa*s@Rj<5x=f3VwkPsuPtXb+l&kqaZe2@M=JC9oFu|09W^U> zfO_=W(^u6<1uctCXBi=gU!gqT1TQs(D;CI{HA_QcqdP#k-VQse*g`w>fj6eZ<#Zhk zL{1{O0@r}hkov$;B9G)xeJptj%LxJ(;g*fQJQL<3GbeulFGtXjj-uf zk{V^`r9eD%blrjTCvdbdFn88uH1m37o-K}tw+EcAx8}Y{{B;BmFCao{D9OC2BL_wg_&Yu%*E}zejj1L$A*2ERm6ONCmDB z%>;FF1E^*0&l~Iah_eO7t;EgJA9R;S}9BQJT{3SIpqbe_c@qIHo{@a7)6-NUF!GQi`yz{o{m61(NTTCzg z^0Cd})~Rmm>W1h$w|2V>cFd{jezWg<&gG)SiLTXqevL|?p32g}RScOhdI`~q0w_|| ztz`!jNB!czeVO4`o;}y{z<1lG$`3zhBs_olO{Xzx#sZpO!d!mu@Rx7aCyrJmZ0&w2 zcSLnw%)YbTHFB;wzGFXrujaV7-<@ls%HE}(k~--JpCZG5h`#OUnh4%Q$%{%uh|2%( zUQn8PPd5H9xvpEZ@5KY62|;u~=O&9~1pLO61|-0_D*u|b{^Nyn<#hpTavSc(xZEsA z&n8$VlJlp`?Zv}K&bcG)9i z5Cts~ShOcDuE`I`fQr~3?Ic%ZuxLMTqug@HEimFv+{iLnTty>EJ_YdcRf6$l4hIe2 zzDwlxW$f_RjSdSbGoM-CWQ{PGdRQU9{p7>C=5hJg8@v^DRU`SVz6nVRnwkshYW=lh z--Nr$_b1vSjCmU7H0e*yhfl^zK;k9UHh85vXDd<-ye1{0-v0*_b>z`U`K0{8Ji4$b zzY! zC>-IW2QVPbA*3Ql*t8Zv$4hEmj4KYHYNVyKS(@Jp6r`4k0&8@zu6OVICYveFBln<7 z6XmLPq^JnL`rC^rL4XX&a7%;noE`E&DF@)=rx#BG-n0Rdm4y0`9-VIVnt~;_a(q;u zI`{QnX`C3(K0yk#0zCL!`NNUwNbYZu;hbMLt42@jo9Y&EIW}i{=0lxkMGaqH((+r~ zw`@f!Hbavnk}y2R5;-r7w2k5xP))wt@gJ_tv*Av*b4P5rkLX{#ZyGNg9Q*jI7NBJ% zd(EX9|F`n=@#WBsjFH9*kai&ipb6C`Id#JD?RnLFWFRNDbeYVVC(c8L@wZNCQjU=6 z^lL&U#eC#C7S+yNJ?K4f_Ek>M_ZL6P&xjFCqa6Mw3WPniT46N^MG1hv9)7!&ks^F> z5kAW)R!y84#&cH&E;--6 z^NTl}yg&7TFGKXht}@QYGfrdp=jG6?(3THl8}A$@WWV`|A>lqwu`xZ9k6K2~RUZtB zy}SK*zs+n}-Tj;hs^VU6VBtQzV;Bkj#lBxFLs9f^02W91fsU)|;4!8ZdQi>(^Ff=D znMv*ii=$imUP+Oc9|W3Qsc9r?3B+#9Oq_(@Rio4>X8rZxp0jt}oL5z4mOl*HyVqNL zMZH(m@a|TJuXraHHg2&x#W7T^ozpp-owefDXdPq0orD|Zy(0^tyFe#-f*KksoD7^h zhcQGXm;3|Ih_bmG+UY6A{So?|ViiiM)8E8u^{;{KGY~Sco`hfML6wgV{m*BKrsc9W z*rZHc^qN{`Y4o#Ep1)!Lzo9cGCpcO+_`<`8V_Aw~hnciBE zpXw#-sxkhKG}m%ij|8olEUl<^6C~_V!S1f_?`YH9)y?;(4psSg`l90o#mxfvn`b}N z!Bin@na?(`7--A3F3(Oe{M!3X^3PH&!*46z?z|tRZx6(oOtl$)SQTkC`|(2_@Xk?3 zmq2gAoOpBJQt5(9$9!7Q^pi)Xva!~9q64z_cEHUL>8}z!VaW7%N5U?Y=DLK#_os!e zuA254OZZBIg1SS(Y=@WzUaAXa?DLUaHVqfGWUM0F=l*%4+!LXX8=n`4==L(4Mq;hA z8ag;*;&b;;xr6crX6tS>>|Lcmn%r}=NGJ441JRWLQ?3Moq`USeOe>dWoUSw;VAMv- zv^gtwE%J7+fwRs<30jo#HGQmCZDihm(m1^5{JewV$D-03U06>m>Jm6Ul(*fen8Q|B$KdndoS z^izh-oKoBvZ9(xhxh~}g3l{4tFPKdhxBhU=>5_wv$cJdlH@{epyeTP~d%dh>*QOYs zO{bO(&W`skE3;#VJd$i05nJ=(tB01G-QMikdR0{^BSs*e6*qK`b5dvgO~!HQ5&pat z-tBp(XZZTq)~1fb&dTN2R$)i`H`wk3xhsp6FGF7mgv3%pK0_QcOiouP_`gP~O6;=R zHr?4={r>dyzGQ_83OcQ)MEzS(X1WBaMsM>}pTSbVPnYS`>wN?EtI_kHpD;*U#h z-Z(0?`Cqahe|>auvx-We#8&!kAMs-s6y6^#xFlsk#oqYg=1lr>)soH`8Js)ScW+0w za_z#ls;e`nRl^tbWqs4RjobgFqY>AP!`_g&{3pk4lh1Z@zG=bJ4cJ!*mzYO65rO+O zYSBV8OL5Se=d)jXJ#n6Fepu55uN9@@2XU3-hI+I=EkJU$fc%;fKixau4CqW_0-#-+4G4%ua^hQx77n*y9cp` zE$a}G26Z?G>h3y%x3!3DeXeb}yV0tiWq9D31SDy!0vhD5D5dIjYh1h^Y_Pds zRpk{Gq#s}Ur)b|hUu0$~-_b@{b$t_S-kmtR;#otP%*>$qv-!2JBA=A~vbQr>Jv!+o z={S*|WEZTt9W{)J1o|zf2@zJn?1KviLVVa?9_~WdDV=6{z15+j)bVN5?qQ?$pM{~+ zm)ds8Z!8Egjd}RwZL96QpcL+`C9lZ%VV5O_qi*K z>^+G+32`4(jj7E=Wn820+!LoZtQ3rA9cX=Y&;QmVKG*G?TIn{<%lh8MI74>2gV7QY z`gGe2HT>VGkWmJSdW;C5{Pl!bK^i7x+9DTy`8>en_1#ZQcKk2)-a0C%eOm)n5hSIg zrMp3q4ryr+1ZfZfr8}iTkPxI%Qb15rIt6JY1VuqQ73q?WHy7@G?tSl#_t(4Qjq%2~ z=XlQ88(eGs)-Ps!^ZVx7^owpZ@hRfl&scnXl&A!86|pcez|fgi2R-(=YDr3fYveAt z|ELvssPvYzDs*3as`_Pyy3AL8hTQbTy0L)|B-4` z&pp{R;ATe@0*5?>Mr>N~lO z45T`6QB|Cy-cU#{hrJUwP=AHjw6tjevL;&b=~WzWL<#>~bbOH)}Du zyV&a08Qy&~Eel<@)VA!$8xL=YEe0K6b@UwohVicRxWGrZ-sk9w-`M+{5Q5rVNG1^? z)D=4SD2U+@Y6f76%E0OY$MbCbE=&{^Ks6|v1GwV*1AoTIzm&{3o}HfY8A&_7z^1I5 z_#+ga-Squ+v-EPei17gdRgx0!5v$=@Kkni|Q9}ZEj*4$RldIRy%dw{yhWTi(SAUap z-&Fj6e$I<}Iq*mba`v7{e2xJ8rH)hqSf8k~!7iy)(_ae&KtQP)NILDWpJF?soIG8) zdqCm7AW^Z?pK+ST+5KrUQ?0`7ro;gN3_V}>$iVrsLmlKcFYHc)DuM!w1g)zm{)Y4a z!Wd9GaXWD>5oWEjGUSL^+8^~gOwfTW~%;-`a%T$Htk<(6^(jVg2A zxrJENiBCSNTRTSrW$GESBYlMm;h!%Zj{Ye);+<^nm~9W{Ts(fIX>?|Wz*=TV0Zk5R z2>bdU$eIKw2SKU`Kz0s^QG$So7CcN4Vu3hxqNDC_jwXpt zcJ#W+H*nznh`?USGinu^vVWH#?_u@#SSE&}&z1zCrEz!lZS3dxl$~2teKplFFo_``Vk8xQU`vrJJ!HPD+nfzBZxVBRrgsd-p_y8hkjieQ!Z{rUTV-;Hm;P zh`8Y@C@;SRng9;us=!K9SyVFYtaB=u3H+KGz=iHzEYX)@a8zNhEJ}rsdsAtQB!jD8 z?7cxkyjNFh1RUktB`wlsHhYUBTiSib$zpWDnd<&*0488{X^mVX`jvX z%O_i!8*RJ#n9L?WN79+Ui(_$r)VEZn@a8#qTl3^mK{^=kS3;$~YDy+q7OlmGn(d0B zl_{2$MXgk$KGk1FYEe+4GbBWdgA+Q8)rZuxf!YIOh7#vaIGj}_K-BGuu^{irc**0j zI{p2NfbUMhPdyCbnO=y{0n#St0;0el=XLSDFR`Ef4)yhgPrya>YIf9h4pnP=73Fz6 zC_78n0;3MZMyy~i9@A1e1b?~<2PFy!E$1LU_j2r{`4|J2ic0d3YbH^+5Kb?z^WEVJ zCgs^D${yw|GkOu1baJAn-dXzIV>Wx)jq`?UupsG^+UM62IN-#gk?0KHpgAEyW)~{CAm{Wr-2+<(Ah8Pw&Hb@x(UICQ}`#X9cR0Y{88Vz3Z z81~j*J=@ASdD#C=WT*0Gl9Gnho`XYN{&pi9iSE??X44aZV~!`I{c?AAYdG3lE3RhX z5LYFgz8wdg5NJD9kRVfFG>##HB%pFn+b=aG{aF&`0aOJ`&)KRbQXucmve9tLnSts_^Pv|fp%BxFH7Gj9{EPi&Koa36xGV!9{4W9q&6EL{`u-5y!?Ipmm7+Y2o6o= z!i-*EY==y)BEluV|29amD8zC!irz#Xgg-dxo zx9YqYc4jQX^M*Vs*%y^Kr$izD;<(?dn=Ll-jUj2W)iNp}!r43ZD;^Be%l8RkAMqHo z=N~k;AE(D4_RG! z{~_<4M?F6QQDFh+^IcJ5^EDa*=^7ZA)rCB?a9a4%VaVowVbyYaSp3j3`Z<)XV1*{| zAnv#U7wAF>K8VE^a#l_cRz)efL|-kW%s12EsW4kW^?5ueN;sK+Scf+H z#fMlVCFjFfl5oZ2IAx>VVHc&`-K%4EQsC*Me@vG*w^rB;Wl=1X*uTXTY zkC8G)7)B5(fKq^-!>0s+H>?4!`J7Q8tz40$bPGl({z!H@tXmeM@rypY?dzxAIugfu z^TQH{AtcF9PQOm6PM8FWA57G8f9RTea3#&Mc54=apl>ROpAxZ&Dn~#H0PIz3DK$2L zkmk_)&cjJFuyT}PKv^ZMvv~E;g;pTztj_Ky>6q9kSk=_F?A$2Sp1HtFxL<``H`kxV z2{ICPcnMLhb~0AG#wCROGOBPCk1km=?T^o&wEf)_`I&!me$V(6MR_shJAK8U(asFn zxq&_bHaNr~Rm{&f6TMtASK}hp$}IMr9tQw!6;00vXeUw{0h>Yt1l;ez=~@7_i2Qbf zc!U+uBZwdu4;SvT0++``;;6m4=atI(=?3Ij2lLisAGT&B#Ow_0YPYt;$8Iq0ta$rsWKuO-5VyE1n)*?{df3 zO@;3}p&yTVPrSnjAunfxgGCuU36wn`ySvB&91{b>$?x%yS1vZ<0!=z#o2tOxV@80o znOhdr>+7O6+)-In}a(`J2%%`4zlthdf@p_XekkAC+J(%=V<)raWx;RQkE|yUb_Hi?)=b+0M2r zeH7MGZ=>R_F$>|<;<`2TzC?gkNW2^|4a z^lDDj0aC?>p}J=LxFm@7gV56~4?edeHs;CN1oPSRZV|sj_bTSIqlJ>Ar>fSUJr~*m zmDb^RLyCDs5M2RC2WLbEa9G4Sn*m7o3+VNuxv;*%T=QI9NqVDADRCYZ6^`c1gQ&ZP z^-S1COKf$!ZT#tfjt}C_esfjoJ2QFK83cZ)+LT#O#!w5X+G$e|PEw-s+4b)qcr{mR zmhMZ8Yb5Lf$g7$Cm4nVqJd! zq2mj%CxrdFTmVR60>8op(wRe(+Tg5VJ3})O92t^-xW9Killkmv!$A`xGq)jiQ8!l4Jn)N%j)2x-6m9aNW zWAl1)v(^BecoxTJnwaK&v&x*no5!@c(%Y@wa`Vg2th}kQy3d6d#bAR@+N93^R*=g3 zbEWy1S8b06$e(>k!6Jnx8IrO|gfIz5?md!AB2aKfZGOMF9V-lsSC3rqfm6`J7h(jv z&A2})`pN4+TY!r5EPb%RxRpJ`==s{P0w)wS3c*pAly_#gu^3^K*nf@l?902 z;1N7DYWUU|!yhc6#h^r4i|6krvTgtF)6xB-8>^`8IowOer9b19J+A3}%RS;=K0_N| zXz$NhGKwcLvd{pB|1IH=Oeyo@~i2zCu z@Zqo_whpY+p|nh=qMZa>Ka11&fkfl|UFyVj%ilx8n~c~ss1#TptY1>w(({ z0sB5+`A-o$x+GG-pHozZR$v|is~-H&f}jm z5pC2=@uT7jXO7mP%Tv;Z|6tQP*T^3M=bM}z^f8PGXAmh_#Gju+1aCF#*%hbYpraBf z<^|CLSjj7#4;z~2kti0aK%tpUkh0lvN?wr)_1(p?Z#w#Q_6Rrk`GZijZf;)zA5I~# zsX$J11}Zryh;^Whjgn;zR6`e$7e{)-HgP)!UAn|6LLF|=Rh51>nKu_TGjy#-#5?XS z&IL2<1Qv&iIYLs?a$_RxydQwM}~N=<)r&pOi^GwoN>hqJ)~zm3I(Xrn*3*Be5j z<&80HAAhKJbkOXMJ}B5I92$SWw7(Yi6iiq>blib#;2(N9l%@Blztsn^{w~S@^Jg1}GVe4rbfktw}nPMZewZVHDxBB>>}-=DXD` z;qi%K?quY_5d+j}1eiR^sm6A7&H4go%ROx82I97@`Niq|q?Ab?9Wk6-DWZJuhf4$% zpK^%ZfXC?yrq>-h{=kOj+Wk>4@x;l##kC&m2$&p2tJlYI92L$yn1Cl&X2+0FF!bLb0?ceiF(-VjL{TYJtrtlm>LBjOo73+n$Q9oOLZ zo7&e^sl9?cE|kLhnWa|l5t&>NG$Xa3V67Tz>hh>$res1c@KZ)5K9`x z3+?v}8QXUY#|XtKuQlKJtr)Amevs7y@`R5FYC*|tD4?nk1HH0z= zB#Sz$*`yXp>mR0Qqx%$1ywo+@64+eKQdsHB)|4_{9`B{roN7gAhkd}WN`!$qQNU_L zO%q{K*9ZIlOp`d#CmGY(?2$NrMo9V$h_vwJ zs3saPZRcVzT7ZDd0TwoNVBH@bt*l$6vb8?5P@Nrozxz9joJ+oQPqJ*i=}dwm=?7Wf z!>!v%i;0|6obTJE<9s(hhC=4~GYlhI%6KV@yj}B6<@1|G0h(zBO&*2_>Z$x_y4K?P z6ARs$LGno}_e?5F)x}_+`)?}Y>@ETf-b~1BTryX6hW>GOu=hRH%eMETqh_m&d+Vm& z|MvkxdP0vLMR3?wP?*eSor}o1R6>f&j^AKer*ix5sX@HAT++2X6u4~dcY^h~@`?nL zym6oap5i>zS5y{NfWk2lhso#!?T45E=>Ng=yEewTah1TOH;>Zzq3zl88_(oB>*D{Y zQ;_6dJvlvEe}#?3B2nkHr;e-I9iUjGZKdYU!A|d7MohD-{tB1bV)TCf)*~E|okWZ4 zEt-n9d___9LX()ED`R*!uGLpI29$eMuK5$-R_^d*Mdj&_dUOL0BUm@*m479UI{Mbo zL~(@W)hlIJs5878jXp%#rWPSkjKa!zwwRYdFZI`^EA~fbYHU#CdN!^KWyMAXC}JIM zkXU`#If?dN?`%3eX*$!HP9^634!tv*L>W{76G_!)wa8L(-;qeF8~F(4B@!Rti3`MwAM&Ae<%Z%Ow_1r#uY)+&TwHXxN zS~-RcjeTK>Bs!F5pF=S%8RNYNihPD@OXkTLJuV@a-qD=nV_aa8`w#*d+79aWIfyHM zh*SW=#swq+xBkwAO&l?<(r#H8SxB}W%lwK&{7Lat-rlYebN#b_Fcud7pN5WzbMp@vg(KvqSApftRan)C8RPS zj)&SLfCiRX_Jx1Z?Yi6ZfGKLjj1O_oLtO_Xv5?3Sdi&zo@TzvxQZiE1u#_^DwJK1y zPvAIvB^z+@I+*`rY&tp2;Dy{4*Um+UeRE$|^t$<7fz#BS$F$cv1Fzn)jGcW3{cFDW zSCxe#b?N`p)4+0oju7B0`Ne*zsr%q1K~e}61O1=tuGCX(hhGZ(N`I$O9~V(iF#w7Q zf>at{txp0iA`lMFW1}uZ5Gq}*JfBq@iG05#D5V*g@^rBNIBSkUeOVc4c+17TwSvm3 zhtaDGss_#+aw|ohe==lkKi)dfjTiSj9Y+cV?I?p43QM0unBLpe^63Emy-`F5&_?)_ z973zNf8I6GhA>f@_U4Qm4R!H+7FzwBj=HbU2?AaM+Xi@zAT)IPuS6q?pccroki;X3 zaoZR5({~--42*_dJ7w9)jlfOX@3Z%vJAENGdau=(zsZiv zOy!}I3irpPO@to%35yw0J^hpKBYx7K5xG#kiHg~YT z_m|t_o@3=2bbA(HX9=KU0Tb+04xD_@8zXj_iBEBUG7^L;fIWkLE&}1hAXSjUf!YU< zA^-^!-5LxbZGXrKVjjRaUOkm=_Q+yU^!>9^EXK&jIPPASa^qP3MtYqZi&mhUHW za+Qiz@5IXI zKXu>E*zaaOGdVT5zSo#!OmWrr@hL-zDxxMnxv|NU2c<_KqC$-j?T0BzDHRCxTJt~J(i@_xqBM1rOkhT>^A?d0)Op!^z3q)k|hD?0G z$yAZjIrPV>RS@?*Ff={?i@1LJof=G=k7z62EkD`QG}x%$zdqeE;&#QWf!{YdwSpmK z=w7w_m>!={Dp6FLNE_*1DaXB^-0?Ao{)BGg{o@N2jk;L*@1>hOHfJ3zR2G9)H7hs2 zz0y2d6#)?^>yBi-lT3Z3b111veUi!Z6bQYUQ!%q}5ARbm$3tGhe7N6mUEZ+}%);2> zPD3@5#7mw>VG({NnVc%Vj61m{yT}*3c3kLgnKmdxWWWGai5rl(sQ`EM3s8n(;qsvF z0qusE?-F+O0M!w&1}H>AWTyzlIx2M_##+Nl8*)Hcl?@#ipqh9QDHk)r79a!bZkUwW z{dRfP#PC2uZ1#+WtKA!_DQVxI3AN@pyS?LA#V};r$-O#rU%^a&cL<+S;Bmn3g|V7! znC%Ee%2o!r2Yic~5UTkI!wA@3??Gr9h0xD#K%k|@yc0$A7wvV&fGs4T4f+D&m%)>a zJ1&To0*K=g0YqqD;j4C#f!ab!Wr$n^`6SY;K?xGn0xa60URd$axe+2cyu1Y`!Kyj`hh~6kSgxO7i^MfnogsHx}_zHA|h{iexmW%eU5CB{=+%{2a ziVw_nfI2MPE;yr6L|Scz2opeCbod4mSde$aK{kfvM>J0$G}D6t=E6K1(u2Yv9T8|E z=>o*|KurhU7eSpIJX`E9l^@Y;r~vg7uoXr~nS5cL&dL0J=SQX$s8O(2$YBTVfl#aq zNi!^9YD0;DGaV1z9gbkL02s&NqV~eOS`!Gp$bI;LQ9KVwIneT)0fOs;Yir#>B!;A> z8gO4R*|iB(p!l8NQ$dg4D^9@r6PFQ^*sW_=ZJ5vc>_yI7l^-68!nrUD0UZXS$ARLZ1B|sC z*egb|a4td8i4R)GTgXWO8|6g=oO;lN%0Uu0j1GqiF(>!n?1HG29L_udPAY_mfzyG* z4}AyDafzq_0nlFpNcEo^=E1JTdf_b#dMJR|aRITKkeWBr0Q(25tRP6ds_|-d5FS!B z!_G7;6{pM^5zwp_IWfd){$A<^uko3^!h#Mb9#oNk9SQKVKx<5-ck>0BA}sb_gA3%W zLVzWTQtqHIf{qC3P7;Sms-WLu$am$O00Llz(ZcL1AlfYU9Xt3;E}SmPa}6fKu)PZV zP2<1ieBJA*f=nr~{5X~C^XbP!vb%gCm0IT={ATd4Aw-O6OuOTdD(iIS$+d_!|BMSK~ zN0vM>7!a6DT{uDKb;~^1Pqchkj9@7^;1LiUki+u9X>o?$uKzUv;QLC0sZs)y1k@a` z0Pw{tXg>h51LhsOJaE-Wr9E$8jL@qZvfOjVp(YX{jsYi(1$cf?MAl=%t^%SX#8Iqp z-Fd`x88By~p5Tw)!Jz-;I-N%^(KJw%v`*m}(S!Y^3g%GkJiG#N3>TgkCm|X_lZIW7 zbjv|P9Z<%gN|l(yt_Jz7X4b726f6k0oe7%@q97o4bAW>&hD}g)t}(aEgmx-!P+WqD z4unhPFm`>IpfN-^Kv)|9I|u4G)34#VNRA>MYzMPr`&Ww&4UM2f4Mq^qbAb582L>I) z%AhCu=b<}xDiC7y!xetY!2^Bb63NTRQ(H-Z`edl!-7=2*L zSi{=aV?bjO5YIw}iI{*}=c`oIyhcf1mqwfjX9}67V3&<;k#fzK|>V~@|`<7FptQfmoY&k z8UvdU@$znP)-1r)Lg)a~1-v}778-EqNDGzKU@BuoX$h|baT!uzs09x$j5wg7b1s5A z^n3v0<_yOdo{qSWJY?KF?|-0JA)+V4S3BQq({LySsYrJ z!ImEbdRHR+oEQ8VGo1JP=kl_uICjvFQj>cwG;-Y zEU4XUkn{oRttAE)H2~~0@{wiGVZi4SV8CXG%>$^86Y10?4L{0(_!0WAz${U~e?im> zjbdQF-w(W0ho0nP#N>8}2#*FWLCPkFi z8_~ZLdE1XrFjb9sb6c+`yuRzUw`atZBGS9Uh4tV;?{?jKZ<^=N)SJIjQzm>Suiw8| zrH5%!(0HLL@m{i#EC!Z3>hw=qFqnxp8U4SK-o%cJ76$4805boT#PWj4tTQ zUC>X5AJkr`D!fqjjnThI@uCgcMVlsui*5yXFN~>i`WGc$8nNQ`=cM=NRJ$C*n6JGW)Mua`|(z``fx=n81zTIb+-k z{uA%6H7Djok(Orbo{#Bt6$bU<(uj8b`SZ{Cc}?WYFI2IimY$5k|L9Oli`>rN;mr0y z^K*-fHuo@1j;gUtj$|=S^x@jFOCzUaZT^&fmqy^uyY?i_yAju#t42{v&EfLuXBS0Y zqLw0~lZSax{rjp~@Dkeo6TVg#ZO+KgZ!SR%lcaM&-}TbS*5%TZ92is8rIlSxc+N1o zNe?{o0Nq5t5uQPK{$|WX&Aa79Ce<*FV@)uDFdv`gVNQsewbTAi{9BmPq6_sWLomKu z=fm_Bhqo-bP_=ZqbOFQUNCAeoyhAUEclqz1tZ?Cf{z(!qn9T(}kmi5?{O=6>|854t z{Fn|8IdO2J#ws3%`ux`>{@C(BOG=77`$u^i9_@eLsw28OP-#|Cry76uvgAUP?<)mL%)V7hvJ*Ut&eSdvjx1HfyLZ73z2uS{8rnkollwXT z{&)>^nHwATuhZ>UXO>J6Oqe~i6UW4#rT?g0_U>K*Ki%Kk8B8aPPx!=K!Tggl#+dWG zixlNj;NZRDm|dDvfu9v^68K6zXGO&ih%oRI#eE|2ADY{V6MSq!-4}H6A1u^8n>J2r zkADzL_Irt6P6ovzT8dXYzd+N+gh}^>A>H{)T@gh+GIP!n*RLPS*D&*-+fgr%xnSnc zqJ~Eln1}iqn|^*itdvGVK8EN=Zgl6A1gW%%*31$1`Y)j({c7pby4yj8U0nFcU_YX2 zO9kl!vMHOL5@X{Wy|&lGL@C^`9KL*!Z-nvT;LObXFzJ~YUO|}+`x*Mx*sLsGZ8I`c zRN@y+SgWhCsK1UZY3}Q!S)tmN9a5}woZFZT3L-DYJI>_W)g~7Fq?B_QZ-;9fwPT{9 zD0lH^6pWh~mk5H<;O_o1G_L{oyMF=kJGxHt~W;OZ(!*kEIk{B{4-O zhCO^Wd|oY>&!_`m@(NHti!F3EmsedtG_So@)iuCDlsb1nK(RFzj!PQ~$21_4NC552vm2a+-|J`)r&?8ALxA zkvZ5;(!5o|5MN&}@P(gmU#G=TPpUXK8}*je5c=6R_YUoRIQqNGzf)3vm_%Z=-=D!_ zWmL`b=U-f|mhrr=G-MtekIPT!oSTJjIMI>c9(JYeD}Si0<|Lv>e(~qi>mTh5F7) zv9zKy;>);RQ^%+aFW_mx!@H=gxw1gR7g0_=D(QO7jNyrvLn2W+yNp(g0f~Lh1&XE@xM1_gHT#7w>Kp>7b! zX-0auVf4MW54$O)z-fqG7yLZSJHH)W;8L1=5DfR4S?)r}`SIANlOzs(l1UyCk9L5e zcZ&6BD4(~uD?G{gv44GYuofmhRhK>IC%-?5>RmNMjB(gqodbf3R(LN zKH{fc>+U-W!MgBEkvx}WHq0)%s+h_oJH+D$*0D6B+tRh5N@_2~TJ=dc=iX{E;+ZiJ zX(hb*q{80^dGWPXb!PY&CUqatt?_uwk0}1cq%#5I1w%WL3oR<5!WyK74V8y`p)N4 za&wabqL!-y*+DVRuQ744ueWyUYTtRFLBiQQtE;Cf6U;u2H8dMIMCmBE?o`yM)e=5_qWSFs}T+?43@(+$FmGxE)(m#H*mFobYvt;MdrR~y5pRB5|;aZeh50r zWnGLoE1a9r;?sDN+AS$pY^rq~kD{gJNNK7f)h`fLUW}ftDYGB#tjMOL-@(M!Hee6v zp80cSSW1nDjxWR#1HU*}AXm!4RqJ8fC}YIdazkBUhCRBZPHojy0rI$~z7Kv5-gBXl z)UIjIZ$WX?ZV^#J0=qVn=tPN~(>t`aQj>u_K6Ev@uRo>cVQ!SUFa)$nFw2LBX%+@Q zrWFnnWRI7R!6M-Kevx-pM4A7I5jY^B5xR=928?GUXVfp3=Nk*MjRkzjH|BN=~GrW0>I(9I#7$jA=eNirs( zRQTK?8}kz{_~{)fgkY2D9Lvz)w=j~@E=bSM%`oZRQODQ7=m@zK8B;*t5_krdf>evk;iyGcFlNS0L{1CE`g0nuc z9UN3r41KwnggWAXOxaAXm|wt$|xv0Tw?1q1uc7rronlZ`91F^?t zFXs3OFG*%-JjL$_oF|h+;|rrWZyYkawvDXra2}aHFbc<7`XXx95vdnhqlN{W zqMa{@Hj{xKhx__Cy$h~6=?tDEF$|~1=R90`;<<7CqcP1qL)VGJZ>uX&&*@nH7rb?me!y!H1!MZq^9EdrqlfxZDNeUW4y4{h_BOi{j)>Ib6$#UlYeKq z=>C(JScbHQ(_xNnX4iN-N*ne}cqEUJOt1rY)&Xy`RyH=f;e;Hz6-K(4)*y{CDiv=0 z(1l@Z>kq>2>PSo#jEb*4{_knTjZMLHj%Co>l!d^sXv^l_$ZX2I-qEw4TI(aUC;zOg(*r`OjP+0hJwsS?>9 zCuUob*wZL1EyY`_L|&#jZ<-_fd(jx}>x-^xt|1QWWi4T)<{^3LjNbw^~{GCI)mV8O+XT{d)cxPk51cG&(%lO(WD(9j9>{L*(otde{mO{w& z`+0Se#itIZ=7t{a!)-IbGEA6_OkSC z8IK_7gsrs5#&gR{ltE9KDnYHz4ICi~jY5S5mw_l?(Z{bs;B1}Hm!Ig#Ya#x8~MAj9} zj((g<`{LS;QxOjCTHDjm&P)ltcb?m4xdV%A?`}d2HrUKusq_fX=j+{-UT6JgaXRvS zy4BdCRX&;fNO;=CcDqmFZnMwK?PMIzkUZ5;l%=xSN!@?Ph%^0@IYZBe?Y^NyxdD0# zLExx*cE>PFFD5?PeE6eK{9XLP3tKSIU>7WWw6%CE9#rh{w%AvtSvp}n?*=fOu%yFy z999{2@!wubzM^FrOiO>)ye8-y&rRx-r#{#p4F!YboVdar})pns_Xl#Fec^mLGxeZ*KE!+^K?Fu@| zM%f{I@h)Jgu*N;{JR#@KBlqv;?muFDsr>s zVRFdPLX?M~>$}T!;Y*P<&aVlSuZHush|_Vr42`B?UlW@Z7B>pgYiV>RQoY0#@F240 zb9thPL4FtdoU9D`@Wm`qI`Z3O+T(e3QapFHOmns{X2hjpva;Skzc6l>`k<|mHQ|!0 z?!DT->COl27;FdUEH&nb{=(mIj?wijav|ZA>>K*|MD14FD$zUo7ECv6$%GB^nbU?e z_yVqbSiW@Yf=3=7bAAoxC!VqehF=C#0oP;4KJ3!?$+^nCtk<9&_kE}Qlx#q4huEAH z$u3LFU@3Yqx1VJSUlKGf>GYxUN6sH+3B3%dhxK&``4TQs${A`4%BV9o$Zrz3W;c_I-Qe2a(VzCwfAy-W zlEyVYs9apgDG?N+m2mhX48y?J3DcQ?p9)>Nnf#8gU zms_tQnIfw13HSr&i5=yZ4qJPiJj*}zV9%XOR@aHj6BvY@fYh(6Z zvZDPr8f!=i-GdliE|>;4 zJUm)h)6_0(UB8b}@tFuM(3}L)%}T3p0n=VFw`66Gxt|3NWR!ehOQOG6@l>$COiHuM z^~DdVk&m>2h3fZ{RkBD`-%H)FFy+Nelf+uY?x*`GotnVYE=$&?n}urgBAbq~os}UQ@d?`VreCbokQH8x{Sbg}`OH|IC>u3boL- z$F-EBe$0hIThp^myazpZ_BrmMzm$^IuE9KfT0rw$Jo5qyonQ~9k6F4^YKwzI;wmnE z`q5XkwcC6Wv<qo1zLUK=)AE6l;jEeU# z#8WO9i*6KGav|7(8pXARaT>L>H6POrL$hctpc7BsAN8hxCcjjPhim1cY#Bw3<(ZDPEq8Hb0^~GT0p6Vv?=}IG#E`qP+TCP;euXJ3YhqqAF~JPlfkWP2jzA@-4g;0g6CM~!iqHE}v- z{Ix*M!gu_{=dSOA4c|j@qBp$QY=nkw60D09cW)WoNpnct)7K}zZ9RS*MYN^g8;$1O z5-5Ykq-svT2vGo80{SX}zlb!aOijycf-N~p&gO-IYwtF4JOy8#Naaq86D(rWjrzy7 zXh<*eI~n%!e1eP)BK4>K=(jan$ZpS(Q(+9rlF26Hg&;PAY6icr| zGkD9uaoMTw>eHY*M9ueXY*q$1$W23NR5x$qoy;UW3@$J$WzoDet{soIOhO`=$+He+vK`w{JYvl4P;{Lh65AdSBDA?d+1nZ%BjblBjfc zaeVQ1aj@%d)S8=1c7%Lar);@OE?-)yQ`%O<V;NXN+@V z|3;14Oqr72d0XS3`TXZD|6F~ZjQ;OG|4+<7>Co}%*lp?~F`-zBUwerSjhG2P4TDNG zkAGrTjU+7?pJ@s_^sI(IN?YrP-DIJlWV? zIvUo&GJfDa*ZS&}y2d0<8`hMOZ-#qr&9s%{hJybz5ug3$KTV#pB=3 zFD%R^B~3N4eeF?dFDZ%t5P(s~;q9t%`tvn5TMgakrn@^or8dWU7!PzL8YJm;;uBZD znUi5QHLwM_dU;D~cw{a9Vbz^9|1nljRz0lvzOUZka98p7a)In;E@E+^xPjHt=#^KOP&iG&Yd#BHhpVXk7nyE$&`_4^@UvLj!}i^V`L~c6WR3Yb^q-VuO`mNRk$~ONbStBay7yD2b&O9H z{_{etZN94JyYj!ElFq~=yxw36rc6JrQ2MlGE{9PUkjsef_Lk*LP9wd7^!e|;qP-G( z;>KFt{S~XpR;=}OqtlI@tt0uQNdw`{`T3b1?`rCaQmf&z_OWtCp;*-VByOPw)76wT z{oE^iqT*HD>q{?Y^DLE&jamAeHpJJDLj@EG8T@<yc5SPE zno3P<5~BC>d1Lb{XJz$mZ1u13sdQFf=c@VDlrI97ibfg4N6MWh=UF*o3oF{%C((TK z_)1m;mTTCKI7RMgIJ{!Mynu@L?zW=+J-vKB#gQ9qTs0c6ZMG9R*0q0+jjbDx_xe4V z>kBIQY?tcobTIUd{^ZeocMu z^D7lN`&b+p>b*t1-s4>%a5l4c99q9cz4pZ0PyV!3442FPi?y;}=f5sSru|OYgHPHo zwBFusuets6H z*g@kCQDR&_>y}#g6jl7fIydWx&P(@9!S(%65=-|&{lEaie{K4s+d0Gc53F6y*~%rP z6e_h+H0l`r+;y}ankuFx9VZ)K>$s;+iTgbq?zo5b%i?plUh)llyOr^ZfeO;S{71jS zc#iNShS3F%@H$f@xyq%Vq|&$gJh<8V^*clF1YT0Zhyah(o47NrBSFiO+}Xu%w7jqA z=iHg{R$gV)x_6Ho3vh{-Ik&HV)t3+zJ2>r@{IQ(9CC~)MJHAPb)7x8dcQ=P)J^1*K zQ2Vk{*|6oqkfx*$5?cEfXGh0#-07)x4oi1-8_b~rrw*z3zur1nIMs5jg+pxDP;5wqc0h>k6a9@JA~$*jL`7JfKG@%H(7X`8 z$f4>)Z0RLm7^!T8PgX8Y%f)|N5JW2Lr4BQTh_#^WR1arxSbJ_P@kxT~}8IaenlWFmuH+n0G$=ddVPLs*&IU%3aOY z8%YeD{m)<3v)u48pKUP5*#0SJo+I024D#C1e&jNJP<`JoNwE6y?2kFa zle_~i^uCeUG8^4Ha^ow_`9CfW0s>b|OZEME*3Ai$!;qD*h%q9HMt+Xz-oua*Zku}z z!W`btH4JB3gJW2-o;u;%+=;}gtem1o`@8$)c}Z=w8e-U-`V`8K%uT+1F=_blaj~jH zJMBoN?Z2&BmI2S~JDa|+gfE1R^(97oic=F$4P%@9=(wLSv3qF6a`*Z99;|&ci;WaY zdGGxFjRJojS2A@9>#nkk`*U~K$SqgQSA;KW*?itIxUJ2OcnUp^(3Z>1J?sdnZ4dUy z4L&sY5^48T#&t_yA(|t;rIuQCKQ(cdfG&OIn)P}$3x}8M(T`UsBH|{a#L6_!UP+QpZE@rJ;A zEQy!i`^Bp=rZ%u@@-4sIynJm=?+@v4?Rxwg(@kFF^*#|?zkYo9$2vrcHbL=yi8K;w zv?XDQzCinknec<|7F$C}SA8B!OIBeyb(3qdP`=rJ=es6zgP}B5c zch+f2fH+xz_~Z9k+i`RZ0piZr(=@Le-mv(=JoZCYq(9uTJoS0-k zq(X}L`)<1#-P31}Cmk`?G~O4#APacw$Ytud>+3uoq;t8Go3JytE~LFy##41SP3*ha zkWjy$+emVjHho`5-I$<^h&NXhc3qvj=U{erS<+r9=~3G&p-xywpLx?(e4!^_Ir-O}>fqVFY(oWsY|X6nsmMZ#IZzt!2K z(87&$G*+<}XsoMWPzt%av2x3BannU(`V|aUekacUD)-neynLn8L*Q31%@l`DUXDUs zU=dU3*pK$wIt!1Q9O1sO-DFCV`Pfog&)-`ck=WG(wdwc~D6=H{Z44t<-gwHj*UB+? zwKI8ihV)&<%=n$OKvB-ZL)#(F^F_ROjH%f%)w^q@OJ+Ui_OA=_?A}R^yR`gkgPL!+ zH=Tt`S>8-f{%-Yl8`1mB&yzavuHarof1I96C9!MD!0V$L>iTHAF>hu2uWx;>^zx9I zM9=u~(&%HIZj%J_#l3oEZEG9F*4C7Gl~TAFukrir;}>@rO0|UBGGG$T^nw?E|DO3v z3o@qKkSSEeT6@qF_l5Xv((CE0*^{8xfg;qH)T^VF>?|yO!|nk|)ejS;Kkeua7WY33 z5Z`W?_)^G>rd+k-?tEw0MRm_f^-q}^x`XN`bln<0KHGF1nRktTFB{JYnnvzrJtng= zRVX%PP3P^N+*CMaeL;o6yT+?v^jPyxwLR`VcKf2KeR3kS2Tg{b^*I4+x>V55SH z|7R5s@}j%$h72|GZRKgb6*^oP>rW0;Ck~O;=rRR_+0f(btkg^h z_q4}0Kj24hedHp02;YwoM~hFQUG9Lagt1W6}EyDSIftVyo`m_u>6B)j+fchRXj#gqrl5m zNk^hYS|dQ(K1N+U#K}I)xOJsgX9U@5N7RK=IdJiLtcg>+Zm3?~MspN^& zTKyoZxZHMTHMYR5cd?1AF<1;P!+_aD4Kem-8>9+0e3AvDNr3K$X zWzhH^&|Rhxr7z0TJC-MixVyPnm{_^`*%%gion?Ew=Yq}U+NM!7at_=E{$YGCM;)j@ z{r&?{Ty;OQdX>$#NvzkXV^EtU*DEw)123bN$@TneLz4K)mkV2~F8RKp^*|XZ?%6>2Xh3h8Xlh zjF&Aw;EX>MoH~+G&ma*}*IPDvdEfq3o*C+`%uUXB+DJrt&a^zFPMde~y@d8g&r&LY z7?Waz-hp{JuV)KEIw*Sk`-2Z7X;bx^wP*1YM`K}QXMaJA7Wb2fQB?6#-9VKyY!P= zF4+p%vv^A_E!9pBn;AN}yZ#iHGP0K{(GN2Bg3pK3dw!h;naSIyc_JrF7!_e@HG|ZS znku5_X(BDL^7rJV=Mlkcn7B@abX)w~39%@S{v!LL1Nf3syvYg9mb%YxhM`sx7-_#G z)`%iB&v8;XNhlUs2|MXlfyU`_bWq`-ac|qDvCU$UoRm|X zgngtfzJ8)1GG_Ps51Jd(dQgkD0atM8Ysn-$_5mjLCECTYVN1WEN6-Ep*Hje;hUC?8 z8$HOLpFF=TTJCT*1}0&8J_7ah^?h4^L(PLn9Ud2nr@{e>}u0jet`ketHLT| z*2z6)7+ ziVxkRmY0wR71rjFMKX&$|A*mI6ygUouUGKcX<%69>C7hX7*%O&Q+EDX?(+@Q_dq4) z-X^=UtMSRfgzP;HQ?DhVdV45qVw z)DI1vK-w_xUp9~uCCn8t=51ca!PRin6uL*;L`>REm{-e;Y=Ihbru zn|_!uydW6f*oZxlj3*a2O~hv?m8|ZDy!IKmt`@?gaM{A!)0=5&kuiNgbDAh`Yx3A? zfLBNPD*yEx31&ZS8ZKHU9+Dfg`QBb1^+kpD-MP;AOD;#>fYb2rb4uQ-lb$QQzCy?N zj5QLz7IjA}v7;uu5J@id11GBSQ>wu=XG7chs@dAr#@RLK8#du_&COUWrnT>mDJ$Fy zn=C{>ONC_cC3(57!U1vTXWR4d?#qwh#iHlrj#@^q zfFCHqeor+DRDG0|^zS#?-D{1uB-K*hc)N2!<~s|_Iy5G##L+<`YV%VF_Fw#64&w$M zCrasqsAt-d_CtPpz3W$eNtdR2G1g+3;8g3Z?aUVnLnjsz<<9A8m>PRNh(5w2 z5vXV{c)D;}Xwj>P2ywKni-Oja$`{vAcF_AN#aoUBI1RL#kOYTW@9R-h*ScBhOA z?Try17=ri`6_@N_H+X>&Ht!WT|7&A*wUc^G@)ra-1$xLWEt-`|G(4bj!@N;8DMc@iGuZ#S6hVd3FZ3?eRnKDbNC`b*wzhQ#MGX^Q=RdN*YW z;DrPIc5W=pop@bKYPV>wSNUJPYuk=lsdfdbI;D>g{=X8sn(dUg8nXDoCE%33E{E~| zhE2~oGAPja88E(?QT&)=mjkK|Blqoab2bR>9b%q@FfnrTo0=3NcHZ*ZRfqp_-N|Ui z8Q4zG;0E6!K#F(7Dz1?%DDbtx@Ufo$EI{oh33P+4c4ro*Tlz^PB1CYqOV&%k$U~rg zN9gu|^fJsozQ3WkyB2@IM=|CW_i#Gyi;Ib9HJ*%{e$GtRX{8s{;33#(rx9R$n-JtT zV>h#5p{@AGz+>nL{`y9J7t?Mxv4N6ydQ3A!oolbbY1rEGM^jVX?s>#CSIcgLBV}Gw zpRptFQuFEi@o6mO5M-`iWMAQ1Mw$yd3lkfirKHZO*~Mi(FGJ{8l@I$9wM0xj1C38=&8`u4_IQSgn^={DJfynUqlOvl>f6LSXLvq^Efe3&`CJdf z*4xPM=~Gtcoc~aR-e@;R_FjJ25;_T;I75aS|_d6oKT8G7449!r<;C(nvNcFVE4h|%7KsqCW0o+Bf3^srf^JZNV{teLiLB@`P>WK5zzqlD17{9vvSw(Oa;} zg>wxzU-M9CS=VQH_4+*m+()mT0lsb}lL)tvZG!R|NEK-Fdra=YHF6vp#b=kHhstW zYLmlSh*QPIP`q~Ncg1qzw+XtnSj-Jd4=2U<_xV6~Q`z_2?uCi*boM%%-;Rx5zlx;o z{}?E_>!duKZkj52M$GKsmF;Md2lW&@mcgNEYw=#}m9vP5v0-K|Yq+wCIqH_4PAfgl zSyrE{7_vDHTAfieRplP`-y=@*h&bI3o}c<_%B}Jec6e^SZH-kpl{MFq-ngs1MjsyL z%0hqNVfy2XqBM#ym~Bqq2n> zNRWP zMhg+pL~CIFb*`>kpaLctTa&W0ZO@h+Z0_|tmy?^GC~5ft-pm`Z?X_2r!jXCY1eQGIH(RWS!py2x$ z%kKHk!`;oTNz%tV)TQ+kduj;;LDusgnKf6F+o-=;D&h*CAW4s)k&N+GcYK=dW!{TD zMc!V|Zs>?M{{XnFyMyJxpJn{UVsT?r>23e`wmFxi)fsJDo0z9tg&<59!yZ-nIW14a z%UZV7L_&5r)H>Yn*XP`O85Wny^ESIy8Z@#53`1{dZ-AbkJrR?KiSzQ?OO_??>?RE(n z-@Y4r&VgQIh`2<>621o`6+g>CdF#l@p-hXDkdOek5B_Ia^DmV=s%X&GgxHvv*}5=y zIWh0>XS4_bwR<+SFQMATvxN_hbZV$R%!tPRSuDW+kmhs_4vGV_yrP5pp2ecVtTEvm zs(;F=B2!V047d^h9<$P49Ys16C)A06=;-K-4EzEFE)+$KFMXHXhfp#LmIwbqWgXt&^ONl+0!HhyT$nI~nb5Dtel?9q z5{Yr6%^H-whj z;>X#7Z6;jFaVrX*4p~prn(bqyzB{~Xb1sRBQ<>Gp+{Pw#Tek+mW=T)uhV6CUKFu=b zvuN!#vX1J)!^Qp9wbF9B;YD2hre!A5@qUs%7V>`D_4Vnsx&9xcEUe?yy3a*R^F3!q z5gB-_J#1q`KW2uw3=>)=dQ~HKjKNj8Xl(A)j&N)Hf7n!|0z-PC!S@Lhz_AKLJ}`L+ z2m75wInj?&;=J$|cT^rF*lx*F8)Li+G9yL2zdtC*AilppGQi+=5g+j!i3kiQoY9Y# z$QSjwVB<%KY!pNvytM~|f|XQcfd%kBdQ>G2TvnWyuy0Aq`Q7%)K~?<@@TSQEmG$-g z7FjmvNW~H(4DP)SU_lCR5vt`S16Tt7IDbYwwPg#rSy@?=bkp{tOG|7a(&XaAa)w`Q z1VDh)Us6I?jUYO^M&+qZ^{IhS?U9L4ex@noMo7q%w!< zKXO#mt)i&Ap7lJ9 zjo&9aNW9z%AID~>mGhCILj%=Z5pecc)`*4{%zx);t9716nXt8PkyVLRA9a|^6@Ko2fH?W%uh9@gzOUPIwzkN*pn41GuLreVoJv+gcS-Tb zg3ZmQ`+HkQrk+ABx5dT?=DMSW7eJL7Wpo);Br#V49$lg#Su&hgS!DlfyP9v}AP0C| zkrW5gClA@sSw{e}u<^5$Komb;*zhk0^x_*j2i2X_HyeSd^*f;N$tKg1&)vb6^b>a*`rN!uotyF-~TMQHK+OE?aWfMDyO zLl|FahHXVsIU%u$;eKXTF2ByT2bU1W1=-o;;YqbQM?E+H^&MBC-?5S=sK} z9R^V8K6$25ATK8HwxXY6KsC?@5JVq9tz>q4r|y0ie{DWGWsyj?wrK@O;i3UHv!vka zF;>=J0c9hUN)ZQPq(JH3FIAw~0_oS6ILRy{fQYufC_=uQSEu|?eIDB9adaq5n!D0+ zB)le&ueDM3`Z?fQvU@A(`h58MJie0|ebw{h+I4FMQx%r&mSAhy%R>BPs%xjPpLC!? z8>vRm^Uz0p;W20_JM=3m=5s&IbQoQ>^|mnk`@rIHh6vD%AS zjg11HF0tDZ5!=J8Pw{6ed~%+-k9C?MxWgC2@~g`QQC9IAD_ZX6HK*&2eQW6o8z_ZbL)EYwCH?U>(`4uT(w?nMR z!Ai{DC$eZ6oMMsCr*dg`r*Za7GiM@M`h{F3pL+I}l~s8ihAHhnjDHhUIt}8lbf8n| ziVZ8J1{0@F!H%B?MP&L7jjSaoH^n3JKN)6RsHEzkn*+?55g7T8P|LJwn?$hPLl zO31eeV19v3JB$8`nE)MOV+Zeo>dYv5X=8!P_zEr~0`(0NhCd;pgfC2Gz%Pb<7RBmd zOA2WV#Jal~{q`F&od)XnR~8Wn75X$o|9Sgi{jMi?AvAzuzYS2^0WKy`g6dC~E69KSuo+rg#kY3lOs^qYS75fbJ-zNxf6u&)ATLjcfOPn_%hJmz ze#*y3Z7+~xm)0VsQ!O?;JXzM1v1PXg+8{=*J*?$oJhOc8GLQb&Tz`9*k`rq#Iboro zv6$0XtLSM+{S7H0VRxZ^q}(!5dxNN>S>DsK;r03V)7`&oT=hM+hIp+N;*O@8+vo8d z>Xz$B>sTu-S7vILWM!R2VdYHQ_uu-hXZ`E}O=8k`TUg9IO57~$I@QXrWu0JL6D|?F z9JmH*>oRy|QF=a% ziR9v6gTQ|pIS`GOZ($-Wgk27ddpFn+j>3_t?W$oF3x3h~^1JFY&kz=y?YQotN(wt~ zMjR^m*;FpyHVL-KvTWi^IvYkOcxVPh;DmuE$dpWB(qqAzZMg`g!B*S*e=(;$#Z3|X zNhimmiSl^>W_aw8o8IVB=n`MnrJkHneK4xFb; z1?j-&Anift!)}WcA>Q#*%KuxyhI!QxEnW~LTVGGzTOkF+n&&DdF%4F|?Icme@nMaF&eilz6K zYF_Re^@RW@(98DU?wv!?<6L--a>IH@3`ToQySt;;TR$l1K#)4_ODzNH$LvCJW>Zaz zx$H3w z>Yla@uh$64(+rjg+AD+|?FdwD&H1;-^V6jqWQ$GpcXLPZFNwWeD`Bh`8|u;x?@k-9 zm^`E^!X+^TRVrcfa|nzC%;P<3p}!OjMM#y|`*dgqWB*2%@O^CQmX#3uaDD>@Lwx$_ z9NXxUrn>#G1r-~I-Q6HSBL%;&N`xzr8SO&+9_;NPa$j1+EyqIb?feye?=PiLt04U^ zw0)mINLvhDK3VW97-UE2OB`b)Ey4e1XE5F)zGD|8inbSBWR@V3cN{E|7AmfY-F93l z+3%AG3Sa<1C$BfflXSMQ{D6mp3W2U<_xq*i1{}VhF=V47^h?ldd}XOV(u(C|ffSc8 z){*(Ek3AeDK4c$Zm_i_^)1St0*se~1sBQ!Y(ENiYV!Edk+E7XDE@wS3sgKKnzht2t&gM!Gyar4x6 zyeR zu`GH_t9J_$@%}H{%ib2){!KQ3AyDyre_vWtpFh%8OloSVY6%i)TgY`5CG(&j$srRt z(v@_GIH}-3o==C@RG31%em6J8FbPH|!zGM>dC2u){KT$5t$9s5bXc9ItAApY2{Yvv z@Xr90_^>0kUFYun{XgLth9Mdk$twxV=(qavUx!vH>HGtwT?;GEaO0a#Uj;%5d24f# z?2fsRkbV+cnc-%oYcc)SfP-yFbZRQwO`-s|4h7A}>j^%TB9cLuDj4?LomXqm`x$yK z9$|~oXFo5Ge7cNOQ3>edeUOYG0#gJu7 z`G`L%I@}y~)<#mne^{fpH#C_{9>7%3NC%3OcAF^!(;Zz29mswR z^&zei)|FXT(d#p8c|JvClD-a$w*kJti%t@IpO7;&^g%%>*#C(QPdG0)kl#pT=V(I3 z3YhPHGd7`L3M8hC6ihxBmCl^;w=dl9y>P1`+F*xy@xW8ktIkkc-8HNfG znr|gW&y8@|ns?Zq|m|C?`jK}?D#YM)3%g6TqOClSLmIig_{oKD-Whd*k zW)|H5?aZp|Hk#OGI5-?VWcnV?Sz}jcKgmC3#-dq5yELVnAV%+J@N1)DPe#uDX%dod zjb`gNn&uf7Yxe;4vd~S5d$H+tJz_8Np~B6=JjG~Fv-=KQ zx1{*+1|AVb7Dc46B02y)o8D5I2!8_6MykRkWk}l^!X?9prl$~+!Rg16{!4s z1QYFfajfbQiS|S*aeyQS=oP0ItU*lgCA?P@PXRH1f-W=C0TnSZMh5tNuyP|z7I>}i z&|fcTikI(tQ|&T`5HL)e(?R@2{z7AwHGZ;(Hr@=vNIscBK!5>heU zX;s_kU#5UJlqN3r`C6XN_rhN>7hts4++SY%M+zUtHCs76m7H7t;`4}!MkJ$Muu>D5 zq_DfY%Eam0S9>Kqgz>Ljwev`Ed!6o>u}{)2QyxFe{yM6DlCJ;PQLT43b?fhzBbVR? zHfDFJwuM>GMftYa4Zgre)9sdetz$dbIx?~hk=lAg7_XT9} zPp-k4y}P-~6z9-^I{$tS^}lDyaUtnkX#tYYS%PJRSkBP@v&t)qO6#+EOBv#eiL*FG5@8Yx`TP+E;{DhV0#Fm1Aa7iDR3Eg38W7} zOXw@>c>C#yy2VrZ#Jbb6Nc~j1iGXT;5KPMBW*cCz)C%rYp+)0NKIY}R^HvJvpNfNy z)~o%cv75q)e1kFq4zhx`FPU1tpas8R(h1_~dFb>NWrS0V#cPf87RowpNi;d{Z7mE> z*Xt+jCn*00PGf3nuz&2fa+te=CbmG(*g>x%p>_0I1Ptu2SP3N0krLLP)>kO?SH_yAfnNn+QiA2MS z6mFK*mSnfWcOb6|wON8jjz)89)oee+J<>u`PMs4uqOOUhwm31^zc*DFm!8iQgx{etXuIx5H1;x!4OD#G5NMokj7yDBL>aUQ^IWzgVm7gnGO_tQKBsJ1ND7BHjzDTXho2%{H;3V3M6|(e;2Iyj~)sLU{*$7 zQ$mvg0F)A#ZSGsAQ7bTuYoH%2D3>yhaoeCLnpC=z|Sq?sQ&pBV#eeeVr zGNO!6oib8+1dw2q{jz)hJMu%Xw@)`S)d{1SZYSGE%8})59*%c&JE8s+*86RZPtSd4 zO?H^SQ!r-d?CS-*0vU2!>kDZ(&@t5;^`?Y|Uh!;?7|N&WcV7F|z#LBZXG;=Kq}K|< zarYaEth(A~XF-dntR1aATb$lTK+P{MR9sBYrkSV{@khqTe~OzZBzi(t91{`=6m$ud z)RAoZsD)eyam<6xLnGt6qLZn?b-yO3?kz*bFnHT;DfnlRBCZqEEV>Ktbc0EcCL`er zjA$S}GAu%=jnC0>y_t8y z^{yG)88`iwbG@u`Hq5zO_kpPYfAay!dIMWgZyTewN2kw8H9C<7Y!}RyCn~!ICNWhP z3>x6l;SYF;AZXm6S8e(;hHXaLvv;l%K`oh83HM?2Z(||=3{zfdC5Z&ukObERyv~GccPCd@Onqz5(r4!Ey80ILI~G5OLQVs3F!l>* zBL7I%)x+WGBkRpB6kf>Krdr91!Pie*2Xk<lorwZ)gsh$ntJ}J0-i}YWWP^i!Xi{Nz{)^Ki`FxUc{=M5G7zQMmBh2(0OAp*C{Dm8yw#vczunq;r|3(NxRm&ZL~O;TTN)z zQV=>cTlRXl|Mrm5u=pa3Py5Wyg z^&hLX)9)Hpg6|g}?;T2f?~66Yh)gr@82de=V}TKsSP0?YbP>XFOuI~Oi;(q2BmMd# zu=`9_lVo=W!i<=8Pq9Q%2f~xt9d{oYNctn+Gs(lShpYy|t91-GT)}$J`45F!nH0~M&|$ioc; zT>QUi!45{F?G4Qln^()mYm!*bY!Qy8NBtow$JWINbVNGP%RN4BQnOcIWZ!hejr9fe z_Tc1_2f0(Z2Skk?}b|5m=Ww6VnMh zLkEk-e*M3z%ge|a3cebci;D8?4CvPBp64xSa7zJ&v;z-m=2yTyu2W(Y%@6OJStnpw z7Q(R2nqRttHNuAEXj6p1j;!EUYmZ71(2YcOTr_Y!(edij*!I-|HVSZy6ZRvFs3~(^ z=*~hi@S8`H4Qw9zjQQ$ftNUJcSy?=6O~!f~vD*Wd*3bKM-g-b1v*(X!;NP$-^5Dhg zJZ)cVJ<(;QPh*6q-CS=YIo@{*PH9y|Ldy;J!s&2!LO;SgH$yi*J7{!EAL#P_vrta% zf-cj^Y&^}rkbmWGtl*h%{kV4GG3MV9)BO`17FYWVPW!XB-E3H@r29R2jR z7pvu5$$}UpFodwX2)Za3^zN$+nWyz^4kFG<7nq@Mu(Rlh&;a6o*flq)&+4zFtuMR% zfQ^zX`DtpUKD}7b4gYgtE`D^$t_<;EO?am(y^4y;W*Mh&2~Hx6=X3Z7&@GpW;Cx=d zu~t{Fk)2i^}Z#|&ku#6iiGTQOwn1?ZAb$_BbZH3q96vC zkmT{Vg5)q&@&z&daeC;NQvgB*MwuAGIIQ2-wCvkzazTo5zYy28(yPpu9 zm4!;R{nF}a-O^3wY7;57yNaAmVXchX^KUBZRuV6#3W|&V9Y{=R&1ODV(Ldjrq+>nh zzmktGW8ye)n`+I5W!aMOaNZ?90q^_axlLdh?HlpVI-F8W*njNC+A+d7(zzwq+{Tce z7#Kar=f|X)D>*(D`{d^v5x0SW{(?w>$KoEe*yNXo;zBAzbw}-eCQl+4y_5ph8hQj| zQqR~A=X1Hr6X%-noVfPKi}^xsz7cpjikuD>8)I2^nd>}(Nhx7ioF=Q{cFtrL1L3{* zh|~)EC6yJm;$fAM?4?r}^kNcS)Vi<)3j4t-41q$TDTw_T-Qyny49<(2Ahao=_9f7y zAA@Xuok@hj)3S)yX?>xXxp_#sB50x#zVZO|ySS2663|2pUshtgGOwsaae>(5EBC4r z1?|ubU(!>3q&jzsDpo_%BRO>VlFIEtd8f_X=FWS*ez~%>cb}w7sq(mIeYEdX^?1SrTAD;F@1KOrlHhNk*lfr`4+d)72_9 zg2~hFpcRpy@3W#J8-uR(gSM5(%S-)!>;2tB-B-EUQKTnI| z4Z#kMdfL=rl{yo0vpKV1sHY^LXuOpy4pai{Ex>@~t1k_>@lq8GB z{?{7us(IPDqk$3Mc)`>kNyLu;E4N`E3Ed67!JsYQN?+Pr`O*X|i#ozzH&~y-0NH3l z4q=EOb~%AK)Q{SC_v!4~bB5uMgNF4#)jaR%|xRC094< zgbQLn%bf@+N=3E$iJ_rD^dgGXt-im6fPQm3s2pa-n^_B!wLqBrYRH zUX}L-(5sFfx~=?hcEnhoHSLkan^td!N0Z@%&HH z(t`>04)&9C|J;z*7I5kD4ma0=-LbxyhD6Eh3Z3Mm+u#k+u*{{o(Jq^!hobCpyH))k zG%5}hM0SQYa;?Uz?2;GjMiH9u#)zzTxaM1Ic4phRwpu5-JK7go*IS(^Ptkk%Vu|fs zjV7(8h#Hc3#IiGgL3JY_I>~hZLLYB*5cmnrfJk)l>&}@X8Mi@?{H#>=c)@Wvnyni9 z!Kb?=I$Qe_buA#=9+$O9xndoWZ}wv=Fu>s>%Brnd3WpVm#VT*(SA}Be{-x9zaJp*D z4|G8xJ1M$e=zYH9fFdf^Pn4X>Je@c$DnAq6Fxr82Q41{UDi|e-CiP%bhEE6S`o=%c zP%x;6>Vnb+AWmDC2NHR44|4cpLt*YRiT@!*f&RlcMwWl<#R6-ardag#>^m75(Lomm zrd&~%T2wqkkOFNBJ+Sx78G}(5h2{Ke0X7e+yaf-!BUUqgEr74b()105k2&ZoH;~DM ziM!^oCvO%?LWn3%>p#z!&*?2m7YbI2qL|f^9Wfm!&qkx|j=5sGaVH}P4qDDRN|GgW zD8tv;K7R#PIz_|MPljTiPfNhP>@TVyJLGW=*k-@4RMGFj90$03UjjZcp!f=bw7~TZ z-NZ0O+F-^_QJrI(mWecun-n|w#uvCIAINFZ%SlFYQWOp33tS2klwg4PcqQD1!5joN ze(YY}Swk3Z47TGPBvB~{R_M)`(w78Fq>|&~%MSwY!&ZEXDV(M1G_HJn)yq7n={L>Z zS)Y)Ay0+TN@XKVL=DP~+edY>#XxVmL7Pt>6?NzR?FB)mgy0lKKV>t1GwB%zSC0eQTy!jj*-E3s^No{l_MhTQ zU`m~tM}@-Jh1q1EdY+?~@e zVwMgLdCI(FFY?i>aY7ScmGC2-O4(RjQ_yOSTEbq@$%0x7S~#v{62(m;HDNX*(d7##M9}R>=&o-tNH_xf<62r8ys9l z5XJsq2gBP2hlMa+qRyQVSwR8ap<-hKjP?wrQxyvaO`h@*7I0#uC;&;qL=)1@&>_=d zQ?Zs)outu#Ti$}f)O*g&*PMoH4m?Jp%gcpej(F-)kqM&~u7nAwp#YfuS5L7u>~5h3 zQvohV-<`_%I-J&~Xkih`Mkt(}TX*|Zmrf+fCg@t*>>Pjp**-nOmme7Y?Eu5W<8)Lf z66zHitbT@C+5Llijf7^D(k!Mabqq?H3-*T{YNBqz`29}0wY6$yX3P8Al*7e@#X5Cx zzyIu1y9Z!f`wJ4Dl~!Hn*xC%HWfqZ zdb&vWt=Yk|Nbx4{q9c&0VHv$^DNbBG7otdtBsi$R;V-F`EGr$J4 zZG*@WZRV>pnJ-WrsptDx{AU*^0Q%gu8HU3PRu8of$u&${S}lZz1fvv+tbxL;w@4^% z8W%t*6$@?$mB@T64Ufz2zSx<;)2z2S=X1F84b`(G48xu($1LJ!8H1EzQOez9l=;#p zVJ`VWz!+_rNHMnAZmOMyZho)E3gfeTQ8X+hkTwj5Kn(6bP0xl&j4H{r8TI`n%3(o3 zPSD=h3FL@mQoBR_8`)s51#o5IImORrg>8u}`Z->Mh{7Uw4V1FvI5kow)G5FVL;R%* z$@kT3m7N-jEB@`lPF4k|95@0Z7E^>i1c;-^YWmB)WJtxLDlK4`0RRHS^S3e*>XU9B zq$!!WN2AtA&hldi=u0<7TonHSqcZACRM8^|q&p1pf^6Ax115 zZ>ghr8E)k$WRzccfh@a$9s6?++W>2mfkm&MmUxhtHB~?-8o)FFpX%Ycx2)W<(81N% zeklBVGAy_;O2W)tQ~wf4UKAAaE^rhg1&;mvN@ z^7`=Oc}opM$6~G6=es)`)>m0lv$x!71_!H+{?T5n`$EAk$SOU=t*}b}%AR5OL4H`1 zMZ%kGBX$L|LH?U;&_H+=-}jD>i;temQrJ9#5DA}K=sFo?Nq}4?NAB;KWg9<2L4~YH zegqKDMSUuqHue5vDLX>zw1$KXj*W?U+1Gx-K%u3sB~jktb1(qdk>w?)wQ7~J9}ko` zo$wAy*0L_09wuJFzy@VoF;tDJ>%Uv>W+Dr?%gZ7!KVXkh&Fa4)Hb{Pn)b?iscS?3y zS~hv(-xgIECX}RheM9l$0`Rw5HC+<-~9aLNM~T}_`}y@B++_&crh-+ z-fU+0;5#BWU(-Whvd6^4SzD#kT(IR*KZ{!B#V3O{&Qg5;vJl`hwyM8S*c<>K~lOS0k3@e6Ije;88Sp+|q_9 zp4q$rGMCxp@(Yw!F!Hz9V%3g!(6ARodd4H`*^11oFEj7H5XlZ~hdDrDxAlF#JDGjY z-hWUL@-drxEoZfuey9}pI$pVG|F~~i-dHGm)0DC}nJ-f$fc<6YSgf1Qc_|??0|Bp- zm;zY0%Sl&^Nz-!GJ~hd=uok?9>9`O+5(X`GDW%6D1m%8+06I9sZ}LkaF;9~MJ%LG- zcZHhIS7IJ;9XQCr!s6w5=!U{yej>*R?Q#?;s*aA0v6VdYLME)PYU~y%nymKsLpY!x z4~>o0(Ph?^^om9u{RAoizXGcyj3Uqz;3)D&*G%QCh@!KYe}**$2rE)NErE_2nwm^q zp$I9;7Q>_sG;?ZPoWj;908s29CoYje;IhO{Fh}qnDnI~|0X1WVf9hLRVjvh>B&7c{ zfjlgc5mLE5GH_6k15Is~_it?0cy~e(Lt4qX4XY^iIVXti9|3O(!8YL^gESAg_st5| z6{Wz~@NRpmQfo8d)3m}Uk3gIHaXC2-*|EpX(|$5k%ri~G;pDmTe!bc*Hc-F%?r~GM zR{Dc>ths}5r>k|A`X*+AHr6w4p z6+mqTVwS#a*x?WupLfx2NP|*((f))Xr&f5;UU9ePI^kd4PTp(mJZkn(Mgr(R__e>hD1_E0Qfr70goP70^W_r*iSEzC!g@a zQJM7b^%iGcN8!nBn~e=6qx7zj>EiTA=?hn9y@8n;x6JhQyY18Rmw|e2j+h&p1+$~; zr^JH-d`U_6qzqRI{q^y~I7K3cn>M|){>Jg7Ltcln9feZwDpaAC`)Zi9a|e&R<5wQ) zP{e;_PiG52I(|;YLS$yrWj4|A8XPSHdMT1501qD%4XZU-sduO;kiwa(P;NMt#04V0 zcd_lET{BN`BEf>4czfp6q3h=%Ovqlmyy2l;Xf)#zRtV%0y@kM^+dR7_kF3%LqU9%(+6J>LXJiRvdWyNseZYW9S)bbYsy;*J+4 zkb1tC;NjAqqFhM}-~#S*!gqS6rr-pohzq#-UR$VP`Lf$B^UQkiJ zq$IfF7@gcyx=m%j3^^XDBsfhUW6a^`G-p>JDDMhKH?UJjCBt9S?5fJ^EY(`0FmFBg^TOKVN1T zoxU4c1dD4?M~v9O|8jtndJs=%TnFbgW}NGywcg9G&itTQ#X|&>i3o ztt~jSfM15tsq|#-EW>9Pij1+p|Lp22<$%jt_5#|9lFP}`;XB+q{ScN!{f&4WhRocf&|al(q@AHccCA@O2e|zVrzp4jHAeruWIZJ_1P<mnu&% zzI5d> zkN}55IWg`dLhkIMqvGz(iK=}`nh2dnQ!iz80(*r&n&h75_rx)(a&q|_}TW|r#V8VCDOWTst6{vS8 zb@x|dLEu(d>o;XOn_Fye^@Y+|Xu5qoXhu$)x_xc2XLuNLNh$z+kUnd5vv^tCY02-n z;OXEpmN(j8jvHU1av>-3IH#_4ZLT8sF2FxCqUOjbt7y~ z>Lih%lAz}Dinh7lo?A`UNfy$jbLEVUr*rZz{-t-%S+4ruUk<3kK^^J-<-Do6I}Z~h z+;z5n;TwPO)7Ktu<<4hDfIG-}`S|)SR_MgT*|c-NWtn)t_f4(AOhj+EWVflxYFoG> z2rAGUEb{Oup}K`pXLAJ>aC-qjN0{K5v98u5eex|r3DEIk#3Og7-|TmJEgn3 zW9SZ%?wSGV?q-PZ@%_$O=bYbK{KKsEJZom}d*A!I>$;%6Kh}PU7jn0fFJh7q&e$j0 zpHR(-H%7@!Zvcny@_2UicQu1aYDDC4V^Dhp2AG`uiqwd+P3a_al#JI(w+e7WE;TKE zHS(HFem6f6{RsUSj4y$*&@+7e`BC(9b8jh&UmYW=Mj(@eLPH0f3SHsq;9;U#zg@Zo z^O9_j&w8p!t!~>^r@`u3o>?H()mDf6dSB2bd+IGGneiO84#``0I)OqO-zB;}5a>wYqUym>9!20P+cYh}uxdp9n%-Ho4l4B_| zaE}N`xTe91hF-2CAC?`dyy0VqOW^J7Zv9?l;p;=&*?xDqo9^SnJjwgyh3GxeC$XSH zg8^>7eanR+Vco9=w3_)V-k9KSHqx6tlbJz>!(`4=vQaWV8(j!n+f-JWe1o;G#sS>E z11{a1dwHdCDpoD=eB;aewCek$lQ#e{@O>QB_;UmS?(Lha`Bk9WHTvx1|5)|x&*h*X zxB6}4IfrcQN#pMIhJym=fY)jvTQ)CfG>z}4o>gAGyeN=nCkiT2g}a(P!ZCRduq6YI zI5Zi!3oOZavR8j}1WC7puMdqTdS8qcf1D{W5&oTR0!9QH^eJ=f^n~0Fps)9rfTAK| zK?~?;s-g^`@nZ|Q*#B{X|7es}6%PsE4Y-c2J7cg;_v1jlGV08wyYus>`J|f;q0WP? zlni@CTVKcT7mwcLObCJ4@`|1s^XVVCSUO$u0oW2^F$so zfawn2m)oE6%gS{1O7k8}+wL0x?BB6{v+DKi9clkJ!Rn!CfS>mXeFLEDuqU4_n{HF_ zrw3W@&E0i;%DWfkvpdbRgArjK?9M;uXu+1gSb4AhZM8q}xxmDjndeZ@w7lOJ)l4tF z@~-d%?emtdW|vetfl3N8SJ+v#>*0GvindEGgF?!!jozL&VpaF#H=NynRK}^=TU?JO z3+fPd=J#7%_fbFx`6Gc2uR%cs+PH^hfATikr{DIXIZFU7+FUXwUX_5u{zXz2bDYkiWCSx6S(K zPEFo7t<%ALzb(N?_Tq!YHWr1rSZa?H5n}&fd289Gz(I&7D9cW-7Hdjw^IrZ42fhlEb*(LW+C`yp#FN zJRSDL7nK#kE)R>F#E+Sya+I+uMT2Mr{2>-fs{Q0-`Hf1g6J2SA$L(7kt*HPl)(=P`woXL&9pYU+H^q@d~ zN>U(UJ96{7aB!vy8cVphQmHx;f_Utg>K!FvX1Uv=m#IcaX3*+xli*~nhoyn*zxp{1+{=Sh{g&4K_F<_ll?=Hg!%~M8*R zJ22>AsTItRvi-S0IfBp%bTVL*Gxk5Uj9J`!+--BO2koL|KXkZv-w|G99_ggHIlx+1 z=Lje@RoZ+#COfuHZmK+D>mIhx>CA~)R^nrtIe9pTyMf{2p6i&9qxDnMv7bX@Ygp>* zXN8rDCWuG$x6xEvoHj8B)g5DIkB47Iyf0gpc8eJn44}tJ*H`;|7WvEh&BcpKtlAiS zoV64}7MpMC9BEk%bqtfp=F2n;?lk0it$pFF5qRoJsk3GCosUW;U{{A4j%$+H)p4ue zJz}jaH)rTDh6=3o#y`GG#`B@fUPYCc87)GF16=HBbX4(Nl&pHlS5!0Vo#474Hd?rO z%NNp#%1;-b4Z`&n`aAGckZ+2DN%ZVRX z+aIouO#z--!jnI23pDSct@}@5ck{%oBAJaG44#T@c?Q)L8{18nN1BG`=vo`3zm7<$ zL|6?Q1BXXcR_vy88KTd~p{qV(zR398Nymt=D^JBiwHX@bgw@!UtE@tKw2UE>@ z%X6kkG7W}-O%eerDctvx4mvnQMSuE^-Mapf+yNj60NLqUYvi@GLY6ufeaEM#+!s^F z<$9L@_1;$FA!Ip;z-HK|4s=#Gihjb90j9HF>s}BXel&p%DtvLmx0n6>2Q_N(n7X4| zrVYw1;GrI~Y#~!6`%_=F$A(2W_$JHF*v9<3@v43GV04D9%M2~Mq&kzZ?{RKkyR&oq z_7^f9tHPpH38gBxdA7VD%vt*>sVXB$7`4^&kN$$HPRq%^$Y}^4b8Ujy&KmVe4O`g^ zm!cShA4A9Bxs=#+?wP6h4t5>4Q0QNI^d9yR?B=(qolS2x9ddV~CQ=T(D(J8h<#j6> z1B8wuFP3oE8lfhC2=by#Fobo|uJ*1?>O33vdCFI_EjQ>#Q^S0K#@pwMaR3Be?oGCT zImj|{2=K#RM-`Vgwfp@3SY)PI71@6*qXx0+`$XV>Q?QGB9Un$T3B;tg42aq|TRdbg zqRSk^;P=MUO+uGR9kH@gk^$&Ga7L}q06@prLWd=Thh#^{r>*Q=c(Bl?h1hK zM(Ydt;A`8~U*6bM8m5&p2~W3gQKZZDBs>amSeBUr*Z!Mn_e{{)Me(bh=YZ}_3?U6E zdC%0%;5+@_pS|i?9%ns=>M)0?Did}8D10C#cjn=G&;OQpa<1R{a>gCGvzf|B{-Y!$ zn9deE+<+RVp@-kIL&tpzqw^KmF{S}>6thQE$Q^{`Sd)gsl9>=vRxGJPm7<`2nPYLZ z52E*m^OsjG>*m`pROo~cw0K?}9L!fx3Yql$e2qKLZ}wr8=Dk46@lc~jG-M3iVLKTg zc|lc?1vj)E=xrb!hp>5O)}8JZ-bK%iw?aI}G7|ROGzxn8^@in}L62r~GO zPPBTE@FQ+>9na|f6BEG^(8ynv0T}sANuExX&xQ0C?U2i*hZ!{25)DYDo{*~r?0#pA za>;FfqJvf@Lx^i#r)ccCPX1JCflH#sb;0TXtef#&Zs0l!d@|~1oiQ;ID-`F~DcI#Q z!$WpFaU5JN;Uv#>V)ON3GGwSxZ zlLDUoNq=(aTD${4-8{;HM9{+3fL53p{idsAttm{%F!tN|_2@F)qP!5mNU%_Mqckej zxyu{skmCBhkeCZ*2(Nl%>b$g`yz{1xvW1_*8y{fS2DVz~PRmm=SrwF21Q<7U1~$_YfP5;{+ytm%w6qd>40qJk*pOWfYn3h)JOFbWGYe| z3By}I8qRl-{y&yhXFogjLdG7swfZdYffmX|?Ad=r6l|av)y~g;bnRDOrlnQ1ju#a$ zNEN2AK%Cgb$-%cQ>{a4xQ&=Z3HpeW`U|_AMfyM>sSWBMHhs^q+`Mq4`6T`nZ1Oi1l#3a zinD;1{~M;5M^nkkXYOo>nlDATm*MJj)=AF61F6EJ+5{P{%(m0fhb#y4Hn4*SsUqZl zM=C6l#-`)P*0KrE^sb1G&G8PucaO6E{`(zvkr8(~s~NHuygAD8EDevx+VA9+)5jZG z0F!*3XLO2ELk0N(7u%gKrHw#ewwayX+u(M^bMrJ*mGm? zZ1SDWJdgWvm(JmFKI37FNpDuj_S%IeJfh0J^!=(?N$f4c+FNe%n7#-3Sj=SS_*`t; z`aBf?C$Syu^1Mat4$$VKN|zwMN_qu4el_cubim*v84=w2TTd0I zEIc>fP5K>hFMO#7g=QY6Z{a0w6A*n^bS^Ea06mv*k7N{LIXh%-+H)^W_bpULPezC% zQrZl70Qs074C@GW?~)J-jF@5*GF#n;2K&$E(*aJuKX;&=#>ccdDroiPX5pd#;*fBh zA4KNP7Tt9ff8B>;>1L%RPzl27n_2hHcfRxnKJWV*7SriGTV^|~Z=-YVGQAGA-Z9XQ zad7nM*(-12Mvsd!8`z`|w28*jWN;mKstd0tKjH4~vHL8V7gdoqQ-<97&Z_k0BDw;I z)4tD?&5w&dPj%R)T{|fFKG0Js;78!i5HZzB$VtAc5yq(-w*e&U?wy0xftLRNEZn+t z(d}$St_Jq8etR3EiI0t8L{)}-Tir?Nk5gCH5hZgA7h5CA1nov*)>H=~YRCjVdlrT^2A4E-#$Bu3uurce zu`oVkU+Pigw}=X!&+Xr_L#l?B+qYp)nEjo$??2XG!8)!yz;hz2jSt<@Lmsm0_KpZT zeU)Wx<`w78P=fUux*2Q#1?QA1)u3>_&CAfIc$qPZC11@dMOJeDQUi#O2~1jaAM!(Q z-)^Entt@L9VhhWGu~RNV*y5*n9FOnmi6sKtX~Dj?Ig)% zt$I4FxsSyij-2iuCKJet^;w<7=pm@UA84@x29D9g7YV7F*@fu&>3v!Sj03};oo*)T zNjv0Pd0_lT(uUc#T_XUBKo(LU-LvGMGwW*)rtAuU6U}!O260+buvB66G9z_g^hLO>BlYgl(iZypRM0IQU5aw2knqs$Q|Iw`Ru4^&~hN;avU!kD{5%-SOk5_b%5q-WKW*2RE&eE`g z*UV|i4F7xylkJN>U8){G9ZSr z=3T}bomV0`!V>GQ0Rwi}p-YrR-W+^JN)~-W(LX>dM(7kTiJOCp6t2dcFuv4c-&DKQ zQSo@+Tkvb>)Erqv*GN(T(3jKnS5Yg7q%s#PnU;#H*=w~58Bsza1Nt#iT5+u7>TqH2 z;%OQ>h?TWQrZ#8jcjm`YVbIZhb6Ti)l0MSM`)l5Pey8fY&6*9f zh=z1QiU)qa`nwy*NT6qqAwsxiAyHKsq~=h(#hih%k?*`R6`9 z$G)%Tt6Km-gT0NffT{zOu2+%aX?rIoTYT2P+F^`Eq#JF9zXnp5bg)5AHRnKFh2YzV z=(=}XGF_LhH>j9uOqk`^qV-#Bc;N3sduX8^V zVkO#>BY#69dPAAFy4?0@C$@qBuzP^_#xK+I)Dl>(Ru4fym#T~VS`2I?8j^!ZJnFv z_Da~ctqtbGCA`DLx3{B?m)h&(H6sj8HEHe%N(E9Url==SqTh=12*(k*kmq`RXf-^HdSRm>|)O)}}2}muS%6!;#^01VZRq$F5LFGeC zv~wKyrS`oEy^1o?LykDHTB}pJKa48BtS!GSRq!eQR#T-JtIDLq%sk2#yKFB?kREf4 z$B2c3P5sfb=Le0XWba1Cj(j5#;Yoager%FR;!x(WoWc9BYad2T5x27TJl~0Ue!d$6 zjMnG3S8CmD{DHPFx#5`9A|3#ZJO!M(Ews#CCD4zj+sd|DcD&$!l==UCQCj&tXS_WLdzkg;pN@iLx2UL$Hd6PGyPG(}VQ&hO6TgGZHyZ_79WZB{~K7i+XrV5`d-1LG+4CrsK zYOlx*`BuJ9)Z@k>s@$MIoO}SaKDz8zm={7;u3PU~;GwM#q50zp2euzs@pOf z%4WJ95<7+S_}-r@d}cHoKkryCVL!+gG6v-6|N90oS3JI^s|GyU;A-Y_s;{f{Q%Z$L zkz1Qk&BoDQyHfOFd$r~CcK*mWV#hA2L8&4`Sux{vjMZYKe2GP#U32R1(&ML%iN}(KnEOrV zoZR3F@b^)w^o#Idxy;V%IB%3#VsC+3mL=TCdLgH&u|HAhoT9?D zyHIIebRE$GI!4i%na!r{YoRIDfUvcWnAj z?Wj~M!=j}?6Hh0^3iTCgijHtNaQuv8K;qu0wH9-0-33RbPshay5mpqGV>GD!}tk(bnU8*_jJ6A*ZqJ3ak zU=Tp+A3G1v*pB5~_U-WQG9LV4D^9cQ*E@eJGwz6WmG+kx+*Ef#LY>5^FQV?#-Du1& z&ptntP%n>GpvRPuASC2be`G)FV?0GL$P-+BD9imIbHhbtA(97L7jOS`V`Zj$TeZ9- z#Jw|{m8w4I(cp8M^ZuzOeiX?D%b`7%3`iMjK&t z@dMEZ3)?$mN7`;iKJdyz>H^+f4Bwd?a8lGq456#zrDit>TM(@E`gpNH10co)gZ#<; zfQpy|eNa1`{D#yJI#`}H+4ba&oJwLMY$4VO)Yx%?o=;w7830Vi5dcZgo1WfPQIaA+ z+3L2)AO8Da(<=pDA2AX>H~ev|%UoZw{PN>xUQd=~uKJ`WYvKL3Q5x%}NzuA*bZn*T@{VidmgW$BuN&vZny z?C7V6(hmKH6k<188i8e>{SegnPo(gt*3IkZQSe#T9s5(==@P$l^y8amO#`ROy|Cdg z>MOUFu%lFTHebl$6XgB8^)gH8e0S}rLv)9K(9O}5olED6anj-*=Nh7?+tEt12}F^y z1?cUDWjjY#YI5u^k)unuw_F{;dh^|?5Q5rA@5xfHxw*K1Mj)qMlN&GD7i&Em`F|M= zPL3quv)HT_H(?FZiX;`ZJ%6>6;;LU6l_TC!gWD~Xh4--V1yFJIDK)dOzySB4z=4i8 zNs8A`(i8YzKAd9qBFIw;&}PRrc3^QrZn-gwlZI?`u`wQc?>J%pVNEoQE> zPFf!H;z6YHZLq%RZW2`YFiWiCHT5yGz*Hzi`Uk;nt%Cw?ov0$+_})D%AKwnP1ZsNT zE1pYk?Ehf>8mh}?(COp->_dA9tL=NjYwgOIw((>5f|<5Y!V4W(HEc6a%T zN1n%K9^ss8pu&8fRGhE#F4EJeu^@%R15(XBKw3??6Rwn@(BgJtw@~<{DoY1*Z`#(w z%nz0 zLqasY#ES99*8P#1DM}x%-o#5$lc0)jy!eWhZX6)Fo`rQxo5w!Fwzd^S91AdU=qres2c*8#TSpZ}mL{RvfABuV|Ss>R|M9RhL8ICvM;2#T)1 zz^Zk1Cg<%1{U&%ea+OWA3om@dQjHy*p_MR8m!jah-Z#|!i0lbxy(BoPy@J|^m>dVl zKB@Q8V^V*}8P7gDx>vM-wiOj?%@>ZgUhZ*P9LC_XD^k3_vsF4P4fMFcqt5@75sr#l zPX-&yR)XD&&XvR3j4oDIdH1G^D=huz5UqHRl_4s@NS-iqKC5}LnG)G)KF=@9{I*k# zT91VvTdx61N6F4cR-l>aV5as{M3r-OUc*(gQ7`f(rJzOEEpBh2R-``z8s@3IQiMUP z*H9JMRkz(?yFU*e=ussv(iWdUFl0(6H&nx#@P4ygZa9F_olcs>hu2SG1W=2{RA@Kg0(>dOM5#Q*Y5QBJHYY8EKzzE;EZqXStB4$CV;>huPHm)PG;VHv z=8DcvKu}o}QQ@5SS~q`HxNv7Wf)!EkdPQb>z&jx1Rr~k>yb7XP>wHb0fibo~8k72g z8)dWa&>BVwpyjE&Z3wkHRbCzIhFxxJb}uNY312@RuVKzMQ1J+pMM3`iG(2 zBBJ)C2dK9gb-d{rYFL_v9QNUDwbx3WAg7xuE&IEG%4;{@i8ix}!mHH! zXUF7VAJJvA!n*xE(1h(mi`K@R=am%DYSL&oQ=F$it(d#*?Rn`)Vb5tQc5^HmCd6aE z&k2I|Xm%nB^HYd=oh}tzj8ls;j`$)feKYfZLrN4nVUBb-oZ!t4S3>7_*zW;mO4*%z zi+Y>{ic0QmDQcB+0JPIY1e9?bpP;Z_Nmov7_Q z2Zj3KFeW?ahbdsc)6I#XrMZ+x1%kX^^Tt*_52nACm>>6b30Q?-`n^& zAmXIm<+T&?$qv^!-S6F#inU1`_PD`4cp!KBJ^Gl`_jF9TMe1asK^je&j}DkG4TXM{ z$rsSkF%z3cP>W10E=|wVSnj_%!Ih*>iw5nA&XuS_#77VIo@eq$l{znX%_h8SEmz$% z*ng!9Yt@_dwiakbbLX-ThWSQci{emP%oRvp5ZRLR-+zMWqXXxK@5pW?e>i0OBg4~o z4h1F1r*O|m?YU{&vekiX?@s-SWm3?-5Ol%)OTb{Q82ALW_h=9U4O9(nX3TxLKb$&A$^uLVru2QNqlUTv?}`&H0sbh29kB@sp<14U;oNVY z{ajykvDR{QmxeZo6qEl%7&_uG@dLpG13Aimw){v5Gqv7sCD(d5%Pf_|U|dVp6E1gF z(o46j(Qc4=$3eTV@U1ITY`#6mjx&Cs(PpMz#eW;qir-RKRpVQcWiL{NwkxNl<%hi; ztbx%3J{z@8pH?DKH*SujGgWdxKT_8jwB z^q}WhK)Ru!wG~_BJxO!3hsHH6q85e7s!9^>SMgV(ynwcY?OzctKhv%?QQ|f+4m4?Z zIW>`XGBJ-C2|el0*EsKZerOp7cue0#h~G@eq6iE18NAjPPj;4eccl5(Dfa2*{2MXL zq^?%}al${HFXFb^ea@Dn(n+xSmw%c`w%m|RGz@05=>@y&e<)Boq2snGu-;CBwzK%2 zxsSJ<82`hAEKDulcDTdzN|Nr3&NqRA#P1anVe~YMq@A^}LCZ4!sq|@bc?3kB-o~@~tXX7}moLF!; z+=)L)SAc2UmyX?sz0<97^YZB?E$L$lzB8SmjX})3^gL-RV#d7Z*fkIJQjJ!f8Bl_Q z=lF!DHl)p4LB|Z;m#JtmJ+&{7jV3$PTJK~yf!rkdfB}7p&8N0M`8dcb~)31SdO z6`Pvp*lb*$KuVfjDj`%OnIf)iaTHdQPHi#O+2w2`hlKrhqSVrF{#&Ar+Hbx_W|JrE zM+p6U3vkq!5a+uPD_{9Z#tjR0KGSRbVhodC6=m~wIc~qa$sO(_%vxj?u7Pa!*nb*7 z`LoquJLMNwL@Zx0D2Ht~YLM#4Ond$<-97&Cmn~IY6azA~5|-YU5ZG!}1g7(}P$)Z1 zoyKQBTkCzGj%4f0vwJCTp66=IkdizCs)mst)`~)HHOg-k%h^1X(l*Q*Xr_1vLHm3= zd-lgGOb2Qpzcmd&(c<7QqF-#mmXwr89MFK*)C$hyMG5~BVu6&1My-Z`;QJ0W;^j*T zo8b-)($_p)ql9Wc12kJ*@1+H1FbZFe#U6dg1CUchZcnB|_uyMRjZ13K9W+8Ed)_XSy>7N+5E#t|Sc^GhNmGYYuIUHkb)k+X*eaaz*lg)LW) zH8rsWg?U4MP^ng_gfM{qF&r>I{FVC?5UFtYZ2Ft9!0A(L?q$N^Xu}kk!F0!$^~rk& zjxQXZwUYR;{o<)-aw^SSUg?BMu&u!Y9tK8VA0I%{{fr?<^0#Bw@_y(x8+mv-lSRH$ zQ1nbW`2-DK!kHIzhp|u0VWYg6jB7bvp$;EgBHq5z`E}CGSY=>>=)8gGD&yY6X4>*a z8~-qd+j^?|1q4j2_&k}gkJwwqXG)pJGludOBt-u87intFvStbtoabO<`z+UBiJ7&{SdOQ_ z^@l@3R*FN@>vml^Ls>!6pT(l6M^=(3=Zos@yF%ivSyWWSFoZ*Hz+9R(0Aq1bAutJ` zq3uC*ls;_Wi6+*lI&Cz5!#IS(jxY7iuX%RXHiAlh;Y}At?@QqHR-s6_8@d2781bi# zU8ZX_w2xARNVtJA-l7RMd zDd<;NYpN8JF)=fU6<;SUH9X&h4s?(^tgYx$82^BVD7$Z;)qav4H3aCehUVrJd$WPG zU_ciy$`AIRI&s6MpPW9`Nc`lbPm#cC{l>)Zp?<#q^$dpJzjH&BwXElIHjiz74GTdR z%{ETsScH|LcMK%F_(aanSl4$Bus{H-Gb( zHpC^iR$~?|$bk1$F&v^RJ;D%$-}u=ZuerZ5=u>>Z0+dOD zB~DdB>XuMSvMb-<8XH!<{Ga$L!I0(l-6sUw0K2>}AqOJUdfdv8gWNErCST=3Ei!G% zKanpZ9MY6A0&A+Ynl@5JM;mOAB{&JB2|<8RmP`#mg8RwnkYkG8auUPd3}@}90PVK+ z5)^CHzyCep15l#b-bzd`A2YszqPsh!_YhU_N(*guhZED$v}p$LW99X}m##*FN+uW= zlC{sKmx?!0gymGnpm9rAa!7wuL0ww&L60JMLRzY1*rwx7G(<}lU_q`jJY zQUV+dl#{)Od!nMqICc*GzvU#imH^?Igxf0cRFAR%+M`)+@cC}r-(OGRTDxqNESo_guxz;cJaFBTis+H(ztMbrX z9)kbATjrPRdk1^J&OVq~%E2Z~q%In{#Y`cCfND2fq`NoAclI@~^$X)D3Dl#%Nt?1> z>aIB>+fRPJtHwqZ6+a~n7N9la^GnBZR0k+Yx3_6ApB{qE?k_nTYifYVSOj+h&{{AQ zev$L`?FSCFe<3yvAX&X3FpOp*UN<7j709Ngt+>heviseBv0svn19IRT2V;GN0JAQ! zhRmk~&0o))>{Yy%G4sszg-&5js*%f>M#$^Dj1=^CA4|(#!3MUJg9w9y5OOI|gaxEx z2%V^AzM`f|AXF+7vu0DeU}*VNcE427E|a#-h*R+u;o)m8^?!NW}f~ zH>Qlt58f~D=D|-1hlWe+(<5Yy9x8D4eEXYgfm~sv$Z?0l)dO_d4n##Ued7ypO-7oO z(1pZIT5WFB?<@LA2jKv;i|OYW57Vsu(@ zP;=tcVo{t|H?ZDhMvPOoJ#ToE!*TX&sX0UPK44$tzp_6O+7f??5TNx6DSn_6%a?3= zi^hS+_Y!FSQ;j$k^h3e6Ll+?E!TK5dr!1S{7G*$7y=RR*gqpH0d9+lY%uYDCx77vIE5_ca&&5xGWC${tXMN`^G1|SIwvTSWM&y zP}kG?zo=3%=nzU5>6`J+sx?1tC<0pW_z&P0vg}y|xY3?6O{=_yT;O2g67+Zz_v3tP z3_>MP>}c_ELJmjLf!GDV!R86^5JOORU&7t#whhCzq8UTv9-(|4fU>TZZRK2c9vN~& zkGf`BFgjeZW>@tB~H_pV>wMA^y7^(@?vqbk=PW`@o^Qs{iUL;SInE(?~_dIggYscr>J?y~lwELlN zr{`s2G%{KrauC{$&1I6snik&)a#T4Mx?3BigU=7$RI%Z?z-7FJzE^?fl{o&bL z53oizW*dnV$YdIeyjmgNX%!v-hhF3p&e2ADjISIVbJSFwxeu#ME%Q&H79^?uo*0fs z?yY)xJJYqyLc2CdeM3oZKMYmESs~}mKgWNS)h6(IEgmJ9C6bI+ItF)XQUx0l%p{7{ zl;sbls?GNP=dh@t-!Z->&2U!t{+mcAcENU(2DTMP1=$QcD~@ph#Y8Fwd0xseBBU5` zRwalK3*gN`S=Y6+!;zFEmEJ8n?f$DsR!H0{u1p7&FS7}QM~E}U8zIf~Hz)@s{E>+Q z6FU@>+TFNEGa>>{byR$|1U|2{GkMi)h!Vn$CsNj?wMEp0L5nSw&fMow9gi9uD9J`b zxn4e@Myd8!qUj4`mz8(r1MS;@=X&-6H}p@$8Q47@QC8sy1>f;33cs=s<5H=jJG zru9hraYdZkyaB|ga!7$pgq(aX<9kn^WB$e9L9RRG=3GsB_ShkVAwrMZ^`zRDC5N+H zabN~{dnzW8)&p40=qK|=V!b6*Cu5zVk_>=38W{1to($U*M$bJllkU)s`&wa{M!H`h=k)>93d+Iu$|&$ z&8WJe0RT5l?0u{+XnOodcLR=0Ttc|N3fi+kC#Ot+VYiaEZVmqVJjZ%lirVWX@e{um9NR>aa=i+}od%HV*2Tx9;*G}y?X=Um@ zb$mP1yDBLko1HxsV!PeFFfbi5-PZIlK$8xbf1>J zfFt}=;-qj|>p(4&4{bL6<|9`9n(5)q+>3o+B?UISA72pz#h=mH=H`HHB^@M%ejvjd z5fk>N=Lf13dIZzM55(f(b{;I;?==7xi~#^}h?z1a9chl1R8zIv&saXpb$kloU+xeG z@fh4~^`f_co=46*+La=~6PeMa5m9Y1SzvVq?GPxef`qxFKRkopo--Oj&f$jDr6 z!TFf-n;Xrq?$J*O(jli}2z@}y87@%-mbi2(^^0S4G&ta>M954@a=fKVk=&*Nj^6kQ z&?;n)$^0N9LAA#uK34kqt)t-d?=^Ka^;_L0h(a1eI$ZJXA%h+BdU?Gi4nHpN?Hr|= z9Evnx{)0V~B^7^d#mP(E?3x=TZd2F0fqF^b%j0h-aP{}C`FC_kr|Sj0`9DY#+D2yfyum1k7v?3qfw!+I`U$xm= zGDR_rS$)k@#+Xsm;*R^)jA! zugduy^>qbX_x@A|Zvj|?;7I%4>Y{DXdi!${ot{1p$jE2rY$n@VU%Ql5S&y&M}Qtku2zRS%;(1!ffP zYEJP%KLnUp%1iwzS^8StKC9yfRQpS^K{JcJB1oAiDAyz0lLmHizUSw7cX^ASQOQ$5 zMe=|x4%&8Ya-%qaRWG2ZW7c<}+HHTx*XiazdtYFgNoC1x*Ftr=6~Vn63~&Z#UQ&e1yseN z4UT!+s^98&dmr?IaM%3a(7I^KpmVqqm<2Gr0^|lzFafX-!^|qH(%s%(W%KF; zzAR%}5N+CjgU)X2ks5zyp|_OBYiaMs3T{?AcTk(lXbx54#$WwD{#*?jg=ZWX*#7o; zCp)@ao$WBVZhkObCEvp~j&JZA&mdk3rju~b!~lmKCs4bxvoADX4h5CBmQ7X38IO;m z3xDK4{A|@V=CET+gN05xFrY-$G%$T^5dyhsnym1>E2$G_i0>FUKIUdyeQMXduFT(j z?6)prgMFaYsN_|n#OOq;7CpfC5&HX)!Vx7)UEmwCj=~lL%1VnP+SIwN0bOx85m2B< z=5NK1dTCKSHrjU@>i;>RnG?cNfS&!#I3AL;RqKZE)g$d& zPX@mWDlU>8q^2tG^`YcT$7%NnGF)kGKDCnk*TJc4V;DlADA;|ZYftOSvDMYkvbNIAT#B@_)qf&?oA@X^;AsIHNp*E8*->jU|kVi>T%4#d-e*SG(OE(3V|{*)V-*SG&G0Rk&8ZKHm%;7B-;N_5s`8 z7nJWVJI7_R>0cU-rMK49Oof~lg`R03dPlVgQn=PU!&WzRy-ptsO8AKe^$f_sZ%0oY z(&6@#Y#Kz;?IT*hASOd)>7TIYN1Iy&j^o~KLmy5}NZ0jZE7K6oD;?5)84JIgbXBgb znqGTRCHwf~d%c3F5XJ!tR>ba~`L&O@V*LvMYj*XHj?0o)Qhe3QVWkh{QTFso&{$gqT zJ)Ow@?!5E~M&8<52y(%0=6-;;f2@U}3R;BKn-6sFtE4)sn|86T!g7rW7_0J6zAjuD zsl0_1YN|B*Mq%%nNSRbK^4fV}Bnl{NzUL75HVC{>T9t=uAz>Yxxx1(>&i<>)Yw688 ze;-2X-ie!FugjcqNs;{~wY>H$w#Qu=QM1O46yp$1<823j+RE8}tv~RIoknJQLZ4Gv zZbp3M%CE*>7}3R}IaDd-DHvrv=ssHTV29(iGlnCN=6zVt9+B7lG>K#ThxtxA>xap= zsgqF-#zF%3n@@RziN6g){Z}Lml0d;7sy}g*WdeiuY{l9r#*R`nNnxCJqg!scYFZ-Q8gKF zpC@Z`9SJcf8uz+d3M6OGg3rXE9+(yCYl>{aMgo1CPg1e%J2%_Y5<5V>>+_X5O?#zx zwLeOh`W%X1{WBiK1)^3YsQg0nv7orNq&~`{vGDK!%$MQ=mAXLxAdC2F2H)?D)Vk7 zTz2hWGck@N*hLRKs(Tm~FMe>43yq%Kjm_cI^fJ^yD`&=W?f<25{wa-#PoH!XQ_B@CDYd^+V?(77G7%3J# zA>^M`#+}P`knH%v3drJl9?!K);PKllbGI9!`o;6Lo8Kr(G#EL)~{p}lD z<-?_R@cgk}tLxG2dZ)p|Srv8XWe*~?s7tRXmHyRT9{)3ZQCQUb{yM|g*ZanI71j

    aL^Y$3bSgtf^xZ1ad=y_btl~vur{}58MBzIaKM>OOG znU&WaXNlbJnQ|Mn*c|sd2Z=a#?$8XEB;B-Ql@S#ch)WPM&~2kTTG#R{r+i0QYw9*_OBC5BbKJa(fb=NAcz7CMG-E*6|}V%(78 z-oA3oaOeVtt3dnvtN7vX*)F@M5nAK!BK$*S-i^PHkTZL=u6ij;7V^|LQ#Ex|k0VcU z8O*}jy`C$4nGyD*^xqDtLo~#VDqX-l$3OPPogEq8t()+8ez(9NqFzXNd4TrLSm`q% zN$fb*{pqKCZr#rabRzc-8Oar(GHKi=@v66;=-ND0Rwr^pYm?*SGb&=v`$3z0IpP7o4dfV40I|gaE{R3*=J~EdBkY*6Vy9y+?c9Dw=#` z?{oKd*eM>@Bg3B>eP%_ebRHM;_6Kla}GE6S(~8&(idLX<{I32Bg!PAO>-5s;Ebi6LibL_iuu8l+34MQIqi zyK|7P8HN~gn3?zXdERHe|HAjfw-$>ZShHC7ocr3>zV<%nT-&*R^*3glp$_JW*_kuF z4yTR|3>x@MUS)D#vLt{*!4BkF&c4MX+u%jKb}~W7NszaKemmQ?nU!f5H$}Cznk91X z*vmg6YRl4&-DVT5!&#Z~~m|)H?OWu-^OQVji3pLfmoaVoJ?xCi1krPFZWl{Q> z&n15vPZf`3h)N#L7jr(B2dr*KN>wc~FjNy+(7Ez=ozSjG2y3P3mho7Y}K^}Bj zuxrn#O9a<24eL?s-kOfk8;DWxM{$0*$yp@!v)Ts}-u-{DC>%0c&y~lVwNJj`)8K!{ zE@|+M?$viLPs28PirZnyX(B*k5YPO7+z};TekbQdV|YiH>~s7>w{|tAR*r}ILQN3Q z+tT@D06XbEapC2&bmg~AVtt3_n|U0s(wEB1Z&W@1OZD$}21X&u2mFc?fm#irU(OKH zpA^?9a*PrH3q4IC%2A%0r%7QC+*ni;)CYMd#Q36~`H70gpG1TR3w9GVzxEHm{{6)F zAI-(wO>y{dHqrkx?_T@Le4t)q_x^@*i1+K)k@0f*N_m1O!2xq2!+j2{+buBer;_~K z>TSo=cNCvJNqz;NZ*?4DJJ`1P&@9dM0l!X>AcVhk^G5n}oN?_w6l&aR-DM`7(+1knzi5zU|&!}%BbZsLfAx9Pn0bTWk_kBydvO2^0C7%L z>P0P7t^_SztwQPpQOhaXxX-_3;Bm#L4$UHtb;OTdWfwZiR@gXv13Wz6)Z%DnaDMER;? ztB=uZb85^60-tYl?wdt4*G*-vNWT1bnSmw(!{-}6oF+nzgHFG29$rsbzyj76(4YqA zC;#xX@ues`UhbJYaZN&*S+f8Q6Ij_vQ=-hq1#Y}w!`7zKt45nWM`u>E)buiAeJ_X-FQ1jt4RDXv*U@L zERhI-_oc!Hu0`a=6WW>+<9o2QNuz}2y^G)c z`h5h|b)`gp8hOC@+0i5^pT@8jpNbpjJz<8wWKQKqLLlyP8y%VN?+s5X7=mAEaUUH< ztHxJcwcJ(8IY$vhM>EInHvWA4zYMk;OAeRb_z#8(bV>Jn*7FTRb@_m@>G@Dy))M$| z5p&%P;)kA}3{ul(OZ)wLehiQxV;F$l!M7ul&ZOH`mKS#@QpTL`8e}MN`~!Vw#@M2Zx=bQ zMT<8qx=QAzU!-2P{x}SV0TS zc4mFc>8LZPt?_5M#x8FyQvJ^Lw^5O3drChsxWDrRYa~H4_SR1TUG~(6+hluaqVW|% zIf(zOVQQ9u-Dc@idLmzvs~<|KLOeAIf7yB+cF}@?$C?~+O-KD*krdMPzvzD4;t?XK zZa7Nn{sDC2l$C})$cH6gMobWp)0rJV1cVRs8UtIv=(rAmNr(!%hDs&Aw#t$?qnn#! zwOeF@lRUfTvi*=}wE9H$js{P z6>itnmq>{sWfh~&achd*!LYY_s`qe4rk4?VrEVM6)yfKLT*cjCMn4IC=$Vu7h)9|# zEcrKES z2@lzpKW?zMG^DmSLhUJaTaXI4U!)64%vMod@d;R`TJ9|fyyn45TY6g!UL`Y6Sb z*L_0(HQCRN%EK2iEcO>P_7@gl+)gXgd95_e6zXx>WG1)*^*arbHV<&!(Y!$V@3m!( ztzIG3L~>+ZM^@*|&i3#Pv1_no5Uk;E0!iPhOy26gI{sT-djGK&y^B$~9r0xA*HF4Ok(YF_zV$sVHu;48LXDLxcMs5(wUYu}@L0i^G#6^Mvackt}KJW_K9G{CY+SLGT4e5(5sbIi;HtV!`AcjEl=pj2HgkDQ?`WHkQQw>ezZTt*D5FUelj3WdXs$Cq`T&mUE??&z1B4JpUy){=<)1ptfsQomM8$w zdE-e`1f}}>f={lxIsaLT1K=8^u3q${cb00o-Q6C@$9datLg!nXnIAaYP41^C)GMfu z@~qv_q?0I&dF^?NUT~0WjU}3dMTqA=_5aV`Sk$AqrJw#wbf6@@T*9w4o zCtuW3=Xfo~Cw0BseOz4iY?;ZHut}+{BADaRHa|!5@a-7}mWIIV+_cw!R}7OfVk_j< z?dG&1I60Cpca^G!NPA#fsT#2qt9q6n4Au)C-+dvPoI2e6_XIT@HdQXv^KiB8Di~2E`x~?6C2f8+a=vqTxju0q?{}Fo#@w_$Yz~X<@(IUCd zt3G8}-g$N)?l=n{7ITgR`SR^eR*{znj8{3%U3+D{BbwQ;)nEVHMRnq?=T=?Zq9q7zfP!+*T8pMO6bgxW+FdIV&$$(Bf^!qqbGh z0HKwtN9Dcc0}*|qEZV3dley0X(KD)CR`KB;#Fyha(lXG&&NXg@GOlgn0`i~jmCBwq zm$}3`fC3B0&a)z|mEweAX!zf~){2y6rePB3tL$E?|K;kB5oSU8tLa_Pv~uTnCe0HgMi0XY7 zdB^>Q#8K^7CRnLONhPKkwF`5pobf_jZDhCpJgG2A2|V81=Vw4Jo;RyyRh(BMf?!u5 zzi>?F`J5Qs*Q+Zo_5j?{;L=9|UBKQ1ELpUG`ijwD8IQiyg&ADn`gV|0!(o?nN~?bl zyt(9lgRf0JL+@Fmofgu>{(iHIKaN*5+iMB+a4sGRwNk$D#EF!g%N0OQHhTe1Gb?jh z!{{-=gqVDH^I{pqU8a5+pz9FtzWe(QO?`9bc~|eaQfb|Mo;B*U`bU4xBfP|X`Rf%b zBo%ixffO)*hN})Cr=Nz5^Up2&56y_Lf-(EiH2l(}EaB7gzt+|aEdsFiIUp?jR2+ML zLAw_?^Y_Ehqq1(1O1`_<$}*3+a(}l(q?iC`-NAQqNVyJl>m}m-rZ`}wd~TUJema zz6YVPw5Wa$TvOjIm0P7_yRnA!s1;inOQ7PcG%ounzQ`R9A5Tsw#Rl;SgfbOSU^pb` zw0B>kxOUfGSUM6s%cXLqo>yC;Ug@S#9ey6HA*o5vp-NV8ub|7AV=&yO075n16J|@; zeMj0pnbn0Jqb92NKJ}hNNfJtuhOj3oEzuvPsJZ*tJ~{0PUBL8vr?U?Y%RnlS zqVf|l!&_l+yBkfROWA7kJ_%@H`Jj_aM|FH7M zIbKm#w^E`8(yVV7cX;}Dl3UkQY^RFc0=<L)Dr@ADQz-@Oy#i!plNgU~kGqR|$2n|RGH zkk*e*(9`QWGtugelP4=<$T@+Pi{THgi&}_P$p!eX1&ixnEE@L_hatb*5)ATczfCmz z6F3Fl8e$x!D|?k>6CE|B>pbyOr3}o!1ePjLx`;Eo_C#jyOCNpjP%8N@;y&7Nk9IWU zkBzzS;ct3g0aNhMOqomTVX;XS)NSJXp!rHO=^w&)CdrRmzeuwE)+ctd`{fb?Vh#=I zFHAa|I6G5p2{IpaD?Ub*`C!LBsU_(h{VkR<=Q{7s2k0EI0)gY9*L#*FUP40Pe7f^$*U@wUz zwunKtiq&H~<-DdfB$GgWqWx^8F$zzdUx7vPAWCNs=0HaSe<>K5fG1bYdu%?;uciL| zZxmj84e0SJqh$r8;GESxd`_-snS^%I7mpx}wwH8d&c#wiyC>IV)U53oRRH7AX}ztJT|=JP^t+n6eUjiJ>D>8xlmW>(colhX zn49vt$C$&8>i>3A_Fxa3+jm2_9^M{pTIYE*%>61hp5EjswS98_&QCn~f9*N)j9{|? z(jIf2)ZHrBJ7u9tR=Mw6j?UJY5*b6cw-4?-$rw28jn5EvI{Ja-te0wB_|WII>(#sI zvvW?j+*&__p049$^3d3r(v@~G%@ILL1>N9lmbCa?M3Ez#c5@D@aAQ;TaPNbJ3I-(X zdbxGU!n7F^_IxR)8M5QySSRCq0G%~G?^)!PxA52^N>@ReLSUMyqu$u{J@PF1>teTn zO1jw(TVLab2c_6K_`hg z6?x3w81^D4sBJY6ohUj}mgx|9b9_c#5%hBX8H{qv(*biG&g71^B^O67+-9j~h-pH9}=eHV8GZ=uq6#aSbhR^Fy2;z8L2`l)YGt3h9*5`ysxn{D@QBWx?bRCz z(xncL~c{QMOobalS7Y)hr&s{W67gU0*~mwzFDvlgEmc(Z}S?CA#f z3VYjhsS+_C9ayj!9n3*1z5Z4?GQBCg|I_Q$iqxNj*#V64aR}wsJ@+4M`{EAM)oxP- znWCWCmaRShCdi(!jO|E<@K)#Pj-W;LGg;^B2*tNKhppEahiVsPA#L@R9-Vb88_Z#x zx!5D&txBA)klgk7#gqSl1j4<@%)y)lx94IZ%sVK ztRe397Zq7H?d*HzCI*;?=*ep+eIbp%|7drEj!XQ6k=Js~`th|K&nwZ)`HY0)U^DfZ z9uCv~zGp(^Eiyd3gn`Sk-tH6vfUC0V=d{ZMF@vnA=x9~nK3G`SCRZG$G-o4Qq zD^EM=aE=aY-ftlXnP5A95(G6l@2ws-Ug|IMa|K#D1C_U;?kF zSa7?XI3$rgwu3zIrho;r*(Z%4R*m9Gg`Hxn;+(p^ryARv-0VLk19I+}IT?L`X{3qoNupTeGfXm=KQ zt!pN_HLyjQ)9ig6&0FI1PV#D|%pTO@w0}CpDcj}>$9Q~Md2W5VuO9L?mc?~JQZ}_v zioZ@OM?4pEbDK$kZqXUret*jEcgAe(S<+zzC)ioC#$@O;+_U+^Y=4`xuIzC)<(R|V zIbeJY_oTeS>Iw`y*Z z{X5rj0-?1ab>(CxRc*ZGWy=Il%<&T|4JXaZA0fQ;P)7ZSz2C;g{UY)fr0bVp;eU4v zUX2L#Bs2IZ9|^s^osp+}3J4zCAF2CE4!3CF2A@d03=LeM_gjyy3Tfc0ccGA9g*Uj06h0=biRA*fmnH9*a2*T4D*hjt{~><|@quWzIIA)0%l6{A2*Nf~@CC z$vx9hFIA8vRT+|qD&5sq@ZN8RV)Eu)_@QRFXH(z{sC4lK)cGUM&#q{;C>--t7Jir> za+odmH||4775x-?a3Y}U(aOcIgn$$yvz-g%vw9?bGQQNXTp0ogb32sN^V%JH4?=J@ z$SY?G+I6BA;G!Gv|91@FS?V4Jxf&q ziCBqc0(D>fXk4lG5F>s21vq5l-uxe{+*drQ76AtqxZ|oZ+!Yc}^i zU+@DNW2QxP1x;E@5tl?uF<9-gN|8BpiHjjaaevg&zT;C;DbRa#dh6nwb5MP5(18kO z>HBKQRv7-Gxf^&+^9#7adpA8bYf5%yCx%}3-PjY!eUDx^3{i}7cwVYDlpZjeHU$(J zvWvsJ3t*kq`+JIJw4{4r3@YzJfbL>Giu*t?L&*IVls=J@&!WkD=I8KdmFNsn=ekwF zb578@*SFUKoWv9iPr4O#_8;en9-q;oS*me@E@61`c4z$vUNrYlCu@G2o&;^=k4t7yLCYR}YJjFj2 za1`Tq@Xjk6)on}bMgb1}VGLRYrgcZBw^`HsM6KDy7*Y%Jyo{~S3kSYi|}?Nayf zdpo=jUJQ*Zxep3hPm7~gGI`FjfK75Uh1WNkWyfAQil^PMO5s(!`yG=yB%8;5@|YVo_;_F$HX+52lI z86fvxt?3gpR~taopMLqFljRG=o+(0awyFZvCzCWkzYM$I zZ?8-Am3J$M4%vE$+VA*)acv{^2;s{Dv0X>=uPbwE-(^0e}TGN&R&sB^Qb`SHnmIu~tKM zOwC~2K{PH96)qomgbgQ`y_x>B2gwN>1-<+30UA0y6zV) zVgbTcx`L<>3b5sa+c zA%nXP?8?;-GhUB>8yGWB4?NEB0c~QdVe1zl4Jde~{ES2u0=IdNl({OLEv2vBhC#t2 z`asR!*_#UU-l?b$M4$JL2+E>%yHd>qjd_x#T2-QLRC?&07r{=ec_yhYt-wUA$r|hE zDIMom(2iiJ%JHY z;L)S@AB+Cbeb@OsGO6E4SdmB0^8;oKeAT4hY2|o36NEj;!Cj3ssrl&|1?=||B^J!G zH;?()&e$%AgXYg?3zlGkou^!%MuyXUFy+WyDk#y*!Mnt;&+w zJgLDKl&oI`OiawhigC{qU(f04tjK{jvyuytCeIFQl0_^UKEgEThW!qeFg5*Ru&7q-O&om6dH&wYPR_jL#|!L+;fnXrzdf?!Ep0()-4!NY{&Hg#ivnO9 zfEbyx@VzQpLbe2Km!|_~cRnuu-dhW234F8B%P9(i)oCxuwT|siRG@3L)t_wLoM>hitNib(sHKQFodL+nRpJM&Nb>Ro*7!ogWP#e$cYRk-W9R1;=j?=F%t5VIWQ zY>#a=+jmqJffn>ZE%Mg4rLUZ0NtS{xHtQ~!SI@7zOw0q{qj|{#uHYH`V=#2Q8tBR! z;RS8o_*VZ`Fz8@5+T6@*jiHPNhaF9ZwmM(=K}=Dz`y{IkA10Q{1TR+*L%4?Rb#jo! zNvfZ~_0r0ae5>Dr#bLuySLSwW;KFaeo$pgMLzk4kg7TG1oo`!r%YXN< z0?>z0pWOk$I;Wt+s~zTm%Sd%v7L#K||)8UnzO6Xn{xh=N({3le-vx@}@-H&&ysz$y$9>~y?w+QN5oi)1} zQ>#Sp6wKiQFwb*fL09sJ&8TR!AZ`wmdgFl@{8!W;?-L+RpI;5CQ}X%>O0JbBk2DLq zh`V-Zmmvc^yS|8<2c2yhn)?8UpddlG<_q|00`KwXy{uIz8it1Y?QLn>Xz9wMhpppn zCz`y-1)3dH`gF45r0a|e*ch0+ruEFEAs^gPLN=84LM<-iW*a1bGe?UvZMIwata&KI;k8l9cBo$L^6r~?<5?_cJrvVj zN1n5E9KKT@go2Bq_YffYHpGZpUF-SLc`D0%-7z*u-fxa}Y&jTv3TMIvUe5~Vv<56i zUpTG)wp-G?IP|`1LkPkew@Ndi{-U+EU1uiDO?E?FhFHIq9$W)tkHtk2nOs&JPbZAn z#M0)>``OmI(4I52!mkfN{s3FUFo>l>&8pV3*=RwKFD6So&_3uBz~cz*Op7OGzLp!& zW6;a>+OgInl*pK~|K1j@#lp=^v>?B6%WwNJbI$LcRwbiZ_QPolKaM9h)gmBTE={N| zmH`m||7I_|!UGKSWf6r_OLcqxd&%N13vJG)#fGCM?p^m<1!X_sD(UKX%S8UHVoLcjg+dWF!}`8ZKKmm80}oC6~Fg2Qmx{Y5dJA4zKOhlhUbSl3-KM{;)|Sx^HJ-iRUfAVlla3+XYh{SLIqN$ z>1?I1uJz(FMSK?7&m_}?7$K^2X>nYtt#|Rmo>z>O!_K?OmY5qq9)!%@*dqox1+>ZO z>;C@68h&34+KLPikoK6TuCMTO!m#Nso`>h)4srm9oS(L+_}Wg@G5`hgA43Zt%pb#5 z|BV@OnD^DpoEUVpBeSYCdn%eiToyB2>%W3JDFd}$ouh-iaq&UeQ(jOjdR-c6?rXaZ z)H3iOg+qeG7DI~v8#;M|H%CPBPV+};=))lIzPPM-nREC66Q>x9Yu5)Qkh3%@!(d2o z^;`$MgC29a3IHFVb)Ech1I{cmjhHR~Eo(()EYOAxjD6jWSn~#^1Y&iydBqeIXt`uz zK7Tym(JUVbF2QWhLS{ihh=04qu;WRyr5vc&L1KDVycs&DuL^_E-qFpjmXSOf)F(0p z%E4&eL&%Izs{GAnEklm~{z5$NXdu<3&S~*xacWGa_ISonto1r-Fd24@Xclya-AtAN zlk&DR87u;~u4u*NjyI=txETw| z5^>)QlwY+7wD@n{1`d<$d$d>GXB2n2**9@POhf#qvT1XqHdCg)tjHX~Q8_tnj%f54 z^71Ghft0Vow5rv&TwK;cSXQr_IqQN@%W?4*^Vo-16VXe!^T{|Uv}I#TdkI(P)|ZpL zbk%2HA9OiA3lVn;*a)u3oCCJcN1X+i>&{9IEabolq+!*M?+t}#Uk`NlgSNOsDy3`2 zsLe)O&(r4s;x#zQos-92COS)lTd=cB2Xj?HH>I380c%%4-(;;ZRR{c(23*lmx^E&~CtP7C>*Vba9t`;X&6!Ir2C-R2m#`zFHxxYUVplFVc*Xzp$6b zNoM^I6#om8|IzDzN%sH${r|ZY`1!@wcn@#D=9ktbR?pb@$|swe_bt4BHPGlz-bZ$d zNGGuai8$w_l_sw)*1H@DFWGzJS#p4n6WEyjd)IBo-gJ@#lY62xYkob0$-MNwwBV<7 z1bH97a!5WpVWN8?x2fznTW-0SmXw;RukaugdB=&}n}+Mx0w0ayWo4HZn7EL`>>Atg zaogc2?q0#;H@D3>g~B|&9js_W^S|<5&9Y{>tEb*sYsLqKXW`X3TR#j%N_`Uu7<(db zwQB~Px_a>QQ=#UIgdNb*01+Z}x zHZUFJ*ulW6#|kwEY`zIJym|BHJylyybp-I`fu|9)TwchzMdVKv%9F@-QyM;!DSrCv z-ViL**KATa-7yI`=k&(x_4ABaVS}T+Z$I^RI>p0mjbn;68-ZtHU^k;Umz{>GCZv#k zd3<-(*r?ld@>j+TSEOvZfRK^S?KV(|$~;juyV-Rc>)y9&F!phmHzVTUzyO z`VH)#0n7T^gn5!BPNg>@^I$^Tev-YR{809Igczrrrk|Gj|- zg(7cBymDKMff9Z&(an!V$==s&mz|jr6N0JT^>St!4obH?X{=+v_WM=77`OU1ej}n_ z^b?;6^fOktj}H2fPd&A*Y8UdG>6?HhB=Ra%EPav++(B_${DrM8$3dj^(bY~)m2oX0 zh)RTBK2T=VjFC*BJ73=%VVtG))Ue#>xbXt&sW)GjHDF`JGN|#ycU33R_b z>vy^}Z-7W5ANu9G^_tmla#Cin`5D>Z=a<{oFK=^3I3;6WyRjzxAkFa#4bvFX2yT0J z;1ENpET+mv8F>fc#!XJl76`|C6Hf3Wqix)Y=OiIkuj-y7Ep5jAnm12`h4N@oT-D~) z8zTnqbku42h`&>^ewQ}?K~nGLtGwj;qyOX5%PsnX4HX}O#rKqquW1b=6m6`@e>B-r zrE3vJ$J+=GaMSWd*hu*K zK6ZI7@nGQVjEyh)0;vjabb#X(LHxSmi5(4l7sD)pM(Yc zyN#$iH7!-dGK+50-IHLU_6%J#bHX1aL9-8wmwaLp9{=vHPEb+#G)4S&9|fVuyf_B0 z#(AklaQ>cj4LA9sa8Ia-UC}XagrL(|$_x4u z1*sgDXIhMzZ|)Rn4%}?OxK*E;&wYYb@X-XB_@NGJw0D*)w7(tu-idi({5-cr!cD!; zrDdyiW4MGO`(6VjzVxm+$(u6tk z?q08ga5`ygv8&!uZ(^!u{y=vdiBTPr^q5N;%bG7^Paw;fQCWMD8s(qMH^k#RMeSUtIb1{?lEAmUeCW4cW@g#x- zE?Kj6ig*2PdUru6vinXU-1L;vy=D&*2Pt+wKHW8o>=d4L{UT@=0qDDHkSLKkQhS ziOZ2fG-?&MtQm*x(A8G(?`BSjv(W!6^qb!Mpg{(f>K0gj=`&{630HM3_|5L+hxV?YvHp{`VFcNxEnlSNb zpoWs+ALy?|t3wVg{w8S-8u{bZ#Gt1OCmWc6%qwVKnxCJ~nyg_fOmDI@d26WZ-3;|x zED9RS#^Kn-_z#Os79R#B=@Gh{<8Q1KhS}~ zP|_TJRLM=J5w*7~cQsg3(2-wZGC8A}%7@eh<&`W&K)50m()pSp&1-L0Q*6nn6^ z0fZT%;Tz2FE%Kp(hmRC_a6U9H7+L(6?N5WmBQTaI)bmG^R@bEJNS!mry6!=6yIo-{ zlFo)?OQbi1j*DKb?LivYmV_|6$3xS-Om16;5j>ieWVET`G<#A3SM?@KuKtahJAu(MU(?gYK4YbFU1L*a`UjfjNS)h$ zg2kHm5W#@lGV~UQ3A8O_3(?T4OrqO=-aUTHlTy#N{OVeJ^V`uO$EWiode@4oG^R;6 zUYs;voT}KS82jTm!ZKoOWtH!f>g*J4-yY1r953sAV$m^*iwfUc;8Li?tgJrnulEZzBrt9E&?9ET2xhEN zJ0$A=;iXt+#oY||dmH@bQZk+rZlVs)YVS9b|2g&$bwXPAhn3LTdqv&<4bUnjtklSy z&nzF~A9k6X(L;3)nN8X0f6bam1|?bldNeyE)MY4N197nl*(PWH zF?#%>PtrC&wGa$>{7X^3CjBKl>LkkZa=2XdZH(tDMhZojNYWB5)WjGenf@diRdXu) z%Q-Vv);^%xY$vH8f$U`O#qCs2?>_1jv5(y}68(e(G=_169OATJB#wma#iW@g(?V;U zgekScBb7f#NgTYuug)}jSMsU&WF`)?TL^{+v$v3_@Hjjy6ILmS)#+TKcq~CTM9NnX zlGN>DMbJYwx#y|!YFC56Pyn2#zV~%0wqvWDBvvSzyTOs`?~4J29M2AcH$U$(IDAG@ zwKK$Wi&VGN{Ja|+co=aDE#L|=O8%p zRyV*e{i%LO;pqgoQxDg>^++08l85S3<))DY@ST1`oiC0CmDTYvrZB0oG5q?n(RbYW z;C*~A#6V^9LHS7_QCeIQJ$nr2OCI;@E8mir;WASj?|n+#cz&H1Lii?Uexy2VC?=56 zvoSqo;Gkx-o>)uisO+aXE!LG3=(e)*>9V5D^zwx~c2<76wz99pBRri_f7`e~{~mIf zo7^{l!l_Sl(IHJ|!kjBY-rnqW0+aDpE6MAGu!5Qvq0)#Jv_Hc)&sP?FRZ1#SKMF|} zYCw#Q+=Ha9>vV-|vYf5rw9mS23GX2rrsy(4Wl4vY)COz!tuFH9Icj@tLJB8jq)&=p z(oxD)n-kcZO(y8!4K1;~e_=)5EljK=)k6GP{dTI;D=r0c+Co-)ukTBP!iyhLDa_-# zIx~-S(%#tUYpTR6iLt*GK=n_&B8P~jFX@{8Fl&JM~S=S@+2( z7oS{OjZSd6kY9kS{z$DrW^+znc?qyiB2X&r*+OrzwB$^ zA$|0ow!ix>UaKpyO^7d5Vj)DBk~hZEHAPr}8_Bn2mv=0nV*hf|@BcjO-&S3iLcrZguTDjKhUvLodvE|GhL)H8y=G|40wi$X#_1YEo#MnS|iq%P)QO zdsU#_>=VbMMI_bTWjzy^^rK&jX6Feg)yE8;_blC7Y@!HQyw8->Q`_a%`zBucAj1^= zU8WS3ikn9W&$#vw&!6EBvF5R=u~Ea`j5Za9WRzI@sEr7Kho+d+y2K8(S+uC58s8r$ z9Kn3|tjGvk$(F5*{Ec8bxBn!myl9Oh#D7Dh+_|*)=9248aQ;*;$6l6vI$ga|z7g@Q zw|j29_ZasJqhq}$mqyx6=1=M*4V;wM`G)>Bk6Z;lRHel`q^gcy!Aq$R+fpQ_waj53 zm03&D{5cOdFoMyC^$^uAa(|7ik)YxL+|+nKvYKgl&rMdsFc+oElwAM(k*1&_A~tNRx8^$826)K6-~aGG^C9rgJ@n;db-q z(&(ZL_Et@o9_E*+lReA^6T6TyCW?=gE+4H9JJn){nQu3^=l!ug{@W~-Wca{;rR4Lr zB*CwQgBCpUBv198Om+oClHoWLpMW~@Q?V7=CXF%H^GvmZz1(U_PEK@*`)e#C9)@Og zx*K1Q1(Wm;{L@u*eN4)a$j zivBaJvdW%W|7pwFMx`aZInbm?>D{*@0jg4Od|JxhY3{FN8Davy`XXy09pT8DOtEVR z18$e&juOqM=C$10RMoMzoNG*RQQl!AEs)$3B1^Xg5Qf_^!g3w0O;%9EhL(ERB3Ze_=F$8C|JGw~eNqt-Abrav3(vTKc!)uq?>UK_ z*>LmI2T!Z@+r?6t%6^g+BXw?XQ=};PGf=Zylwxe@mK<~+r_?8G!Aun9!zYjq(l&Lj zgB;W#*_sv+;^_E7NaQVPT+U#Pf3iVj;hd!kx1-LT>y?WBZghNq0Q-f)X+)a-W2?w# zYi>aKz5SxZPlwh|0pIpJo=jnNw=#76Tt|K#PQ^9Ax!e55Gv<7b>TS&j3yN#Dc1E_a zHtZ7Y)?4};34+(Xn79d!as!S$h=`-iahYmoGYJns_IoQ@eVOd)NFWD=JG_7M48Nwk zaFF&bk!EHYczn3#H{OGw0}}s(5KXG>5zXIBWGQShLLCgLT*5HfXNiCmOW=>b*8#TkPC6ff8LQx<@(6;A55#X z@W;Z8m(p^4A%MM$$b_IxWIIJK52EVeflwQdgq?6wPy31LpJhJ))6cG%oEiQL>;IdM zpI(#BS=LSQ(7@7a4HHlT8`Q_Ze1>Z9LmHr6BbGUeqpeKJvk~GM9$gqKDsD!<({mv`}` zl`{GYi(WS=#0n%;-JkwUsUpUXC)&=f;KQe4*#thAjWr)9=-(XJt~r%>#Eryc9~}y; z<689Zgx)ud((11)enSBRrx3|bpt6fMw7(TX5<2p`{M&!Le>$F;X7i`?e#6g)MmBV| zb_A^j&Tpv13=z4>t5G#y!WF$t^Q*~regg@K!M-4mR_C_V=N&9<)=QBjmk#5cj-D$9QXGZr1=ZogBYFEp`1 zUasfuQKR8h_&LFy*yaTwp9HA5k?w}tKmJQU#1`Y#-F+vj#j5KxEdEVzx|iIH-z7R( z2U2j9+{%0WNoWEYu;f~%6v6eEu3E=k?0(8D0oMf#uTzdxWaMA&6wzrcT(96vfv8&E zQx5t`Xtjf5rPuT{7Wyy!{2%t-Iw;Prc^gb{3GSBQ&LF`pxVzh+!3jFJB)Hq4!JQz% zgS)#EAQ0T$H6eSG=Xu}Xw_CMe?H~KkR&7-(1qCzA>HD0kukOCO&lpK)&~2;&0(9&T zVoM@3YF7k7+@0h6<^XVv3=FQeY0L!WuAcrrpA!MDmlXz(Y@+<&slE9kv$_s?0LZzk zK5eoI8|aV`ngZfx)2m#9uvEX!Z6N3J&^SP`ovA$}P4AXIgU%1AIU-)zCl7vjD`b4K-*((SBt%GH7nFq+t@9}W19@^l z&H5);Ktt!Eol0OrJPy^XpC4#_!Z!i<(dj71mL8iPhE2ADKEuy0W1FK*fODmOS+5i$ zAw#hUEp;pMsaX64$bBwIE)#Iy*7=i>i|io^&$ex){nxnO_&R`b^J^uBO1SISze`UG zgi??Q^V9w{YQMokXt+z6^M4UWo-o<`+#^qc)9p$6n*NQ{v^T0oFAzfXxD0=PJz(;l zgGg%)puBD(rqC}d{1XprxPyeT*lor8rWjU}ZJLOHJNz(6$Py*xpv*7zNF_t~C)3KO zu{CXPRhj(FKwghLdtgVFKZHs2HUk~?^fqboZ9^j(vYz=NO&XwFyC5I*+O0xvkw!s;UR97h!-v{> zJ*DZ?J=vq$TRDIL`jovfbCuC>$eb6?WPeRF3Rf)ByZ}oKae!oxlp$0YT#;Kw;Y;N= z(2?c;Hle08VlK^^Rw^CY8nU^Dd0i^2mOiJu=`SCxh@qD~Hu?wXeMIE}f;q0_@VP_*fJ33RAus(&Kf=&%8{Z9RRs z&8X&*_aOuGhoPE46bTVQGPLKdS*Tk!j0433s6LGxsg_QzJ>MnOLUZzioGntcaV~> zTjr`d+C?eLrt2!ES*Rv_&3djDb!#Ac{fB{7sIDGC&N7LM$s5}7ufzWnLY=R-%kQew zoe)%9cWpvBXk|}146#mkzH$H|`K&dN(n|kEVi0%q+Kc~Q~?%gXP^v!r>mvoR!1GTR&xwH=N5bgk|A|VA}WE>Ws(^0waO^W%KDVJhIRgNrXuB zXm=FAdmbrXoxE@fRMPBycA&!!*6?Q`G{Vdb2894FbB_MSWfO)q0Et^>*8s1_;6y3e z2;U0u)_z?XDWxbjNW*uOt-SByL1lS_(Ur$L)5ie7ErIZ`@jkdq00RscP9|lms9H_S|<9lrVJnT8dT+P0^bSh#e=Tvepi*Ac5bGD`Ldn8crgWp%}VWLH$wDc0` zD>9$9h$fl&bpQK#7o*%GLOs zi7+qx=qG*62z{TkeKoAjA(&??1JtB8-#AjhmL!YWjs?x(&BH?`6!@uUN$|lX=i}N|nXDb1c89eoxmniOT6F zh|p+F*Zvq*5G9WktWI9`t$5f`U{g+X&S}FSF$kZN{+R=k$^PYl(SefE%Eq};{Mb14 z__JTC#FL%gJp49C1eBx%{u>ZF#P);iAD~DEiv<91+lD~qC2hK_`r8%?vod3VB zPsS^-f1qYz?K?vpS7}u#Zo}Q$Od;hK(N@oN+Iy1A1bvK_yaIh*TI5s5%lI5V_#;N4 z?GalmD603H9TKL}fB~LltE)vQmpu=BvB6nvA-y0a!%;wCn&au3w7(0pFm#2hm~v%I zzm~e~gN*0AVfP^rECo{r1WA2H5I?f&ZJNP*Xond^wax;7_UI)V@+qSP>2Rqc2S+Lv znDb2etVs&SraUNyX7;_0ntA}Fv2I0KTaM1}t#$eA;M zAk13H-w6b)bbNL6JUg)!r zc>D8CU%g%bP+Kw1A6>DVgbn}bteim{c?KRyYq-Kd#ciGeF z-6iM7I4LKsuJ!cs>9PB5{LGH)b zzLyF1rjY8*w)?_V_0A(+76=L(CudGMVbYI@6EQ}56|cf)N(kRaiWZX3jNm0nMAZ%4 zogDLtJx$Kxl$@;cH|?kbNc%n28@GtOnf75eWkLf|G454t({0=^LdZx<6#-dd&^c^j zNzVQ{Hv;cYX^rU~TKzZaq;8%`32ZaYCwS!3Or6(P68S_*{*YE#=oN@V$ET_W zsQ1rcP3lA0PXc)NUf0|cVJjBia#u%Vy&NGiF;1;kdJkIk!oj|A4=$_&&Dynib~f8q+hM-X!%; zl7#uO1w7q~X`Eeq-Fv{LzosN_fitq!q;O{sy&8|$dyENRw{nC{hF?$b+#jR~_C*z- zkJvY!mrb6Qk@1+X)0o7M@dDTZX!42BtDqxqAH0v#TtvKoFP5mj4NRhKtu#b1d? z7-uCrY>n3|2WRCg3|i-xgfq9+<%L=Q6ACg1D0N$YR39RH!Fh(7Bk519FINZvv{v*; z%eu^Q*y9LTkyS;Rir9{H%X$m|sR$j|DYsW@qBLOa5KCrr1c`KYQ2hr)d>7eXm$FbE z63D=R9@m-W%i1s)6alLO2=j;8Bh%on{~j%ZmRfQ=z8aPNHh48D&;JF{ma8&chY-;0 zlb)Yged;bWK&n>v3P^3DFgpB~`3H{xV1_uR+`N;1=HE~-UaDC}zi*nX?BQ@3#ZKgN zD0LMGby!mCry)CCr0_vKSL8NkVSJlIxZ1N`F32%;EJbEjK&ETip&$x!*QophofJ+hZ9z(VX-n zR^$`eMVmkoilu9nG0(bnVJvIvs+5dJO8JFpdh_!^o0WQ5l{zV?v>((b7%y{o;v{TZ z2_p-Ppv8WUdmXClqR|$D9nHm_=uQgQgKH=zaPy9>7J|#H^eG2x&2`(cCW4p=*7Vtr z&i<@sAn&-Oqkl%w*aZr&u8jN9?~-}yk9Z)q*@gs1)nIja4BMxcKecXSl|gu69dGyMHcVdu(*^aW-8} z&0tbt&DYOJi=zM$-CY;S*^Nu^&P@G>DALBYF2v}r3bS1jS=6B`>sLljhb;szsDLUq z{*GuZ?VoIi3ob{%SrBYU)207$u(h(J#2_=#%|DL`A#;`&o7TajNFOVju?|3h#KqdP z;rYCBNj>x1l@J}!w#pZW1A2w&$ZB|A0EPJ0)FH;xThGq=52n0+zdZC{>LOO2i|~%s zU7O{bedB-Z3ND7UW3E>U)k?~>B%6Wm#mV3F8b*W?{h2?mOFt~_;39%NzKAYIK)N?} zOg&E}sxI9@$HfH6?35)M!{1@DP&}1r#4O0B`spxHgm^)-BisIaV=4QSyUt036T!_sLH}U-#g*c-($Z+&4ZJ#PUA$;zOw_ygy9x5~oxP zsHjT3<)xE3bu~AZYkRc4CG!$j3NOFT5lll?)VQ!$-e5<5GR3VoRd{6VfrZKD|9mF& zX}olSN(?V?K0g!1vr!~3(Mk0zb+enTasJ9bQk<3=CftD&3_~gFf49(g5|Bq-$_@lU zzL7xXfq92#qdO-ej3&}b0RG5lu9=2dKq?12T%sh>jXT}^!RZ6J!rNyHt)@o_u|oh) z829-VXOxE;ZCv6l`ZI62td8#gFSsK9)Qz|^E3Nt!gO!w1i?zOE>-!i#ra z^GW32!$_-%M10JO+Vw9~b%v=b zB6sLr?<4=TDh(_#Qn%u;VkVP)^U`1052C?j+WVM`1~;)ub*qQ+uo0y4y1hzTU5%}F zk=dV-Ns3a64Ud@^7sMpheOD5~wjrVBpwGW@HMx#W_xdy8pKKs^_UD}#p*=8>dVhMU zG9ipz&23%Op)dVLlWHG4+I1VfjkKX)JgkDf7o$#|)REaE)0^cA$R~Qevp5Wu1eXDE zm(R>TcAUYUrbJwHgB32#-z?R!KJ(WX#xME%V7dp8jsR<;Pr0Ei1edy+5iY91^!e;S z^Crnki+tc7s3#DPy%P!*UJ(G4(3xr=4vU^H>|g7mR!4XKuhs==9qoE^JHP)5u)@w_ zpg=)nIC5vLoD$(trbVuC0N|!#x=1-O3bD?A!b?^aAi8W>__fs!+2|U3-55U^`Xa9f zzCZ@oWnEy25H{S3#4`AlRIBw&U-c zYxApx;+v`RV`=W~21#dw(N2DRU8kZMuQJvVT|{w+!v)YQRzl8hgWMPFfiFeWNS8I}+y zD*p6UGjq_}EnqOYoluPObP_!RVG>?X3?O7{y#UbsxTTfPAVhh7Fz+zJeT>4`f?d^C zs&s%1i=JIiRo%rWnzVw3;%!m)WF^XYBz-}D3i2$xzJEfcvm@NWUewADb+vgqOO&0t ze~S}kS^gG6yC`cs^Q_`VI?rqhD*S_ zE74%gq)U=hm2_Qp{s7D}Ki4%h9A{4^ewoc_=kwG7yTf$cTVura%Y`Lc!J4e{i<{ti z93yW*JTRx725I3|7%ue3fOk)D+b}gY@=pq(uNLJS;v*$#UKElFXee^JdJ$iVXZzInxY=d2DzoRj~hh!3O2I{rik%fH4{9u{pyG94@{`-Wwv(^RDK=CNms*pL412Ca))%sQ<6L+>bh2wWz0Zp5AySclr zm)DM_j8!G8Vt}l^$oTu*o92K|DFqgh*(nA8w&3Gu@9^qb6p=JgOROTR1ey=v6znb_d0;M@?hd+tw}^)SsZ}#B9i9#Ej+au?gHu~S zA?&(c4zY4eWUXe80#72Ktna>?E3w4dwJDX|_F7S1SNz8uUxd9W!IYBDC=1@S;EO^r z|BkbjI%gqZ25F2?G8;W+QR%TDhTu1zc?7v>8VXD`zW~zQP5h3|Emc!tGi3EZ_W8|! z`uRn_2(L~7TK~~n*WQgl&QSG=1ryU(fiNa0zt}tS?rQkJQ!Mv4Inp|^(}h9<80cRt z5e!NeH+DT!hJA`s(H-P_{MK;hYy>i%hpYhq#JKK3Me=rJijL@X_>*K(Ew|Z99TfTij(+Zb(hSm8VuEO>4Na+z4oVO;-@woq_S}&r6isXh{X# ztL1hQi1>(SsaM=!JZ#evph)alqA3+A`1oi(Qtw08sE(w0Fl=yc(Nuq%yj;|}YC!tu z)$N(j%_)lM+{fR)>T_VcVb{XRSSz&=?f`^r_gF2W+Wz}drvvMLq)+azKfz#awA?0! z$9}fjV`KnRvQyuezPqAqcF}lxN4K4NVzz5HxAv1XWfM+YPQyP~o!^3WqgdXfhc3&y z3S0lyDAQSM^p>nNSlGmUJ^!LI$TAA_u4FzHruoV%$>qmpNTQkGp7};7)sS0!ahk47-PqUx?U_@9%-M!{n;aH3{t7{d-TQbS*~(CYa%*-ERzKvotg zOfXf!iJ_?L>dF!geA44hlA#`!qyAyZm=v*DI}4FLWd(G*uYs|jKo00t_SEble>h7r z=*IsD0c^sK2M$3a$pX`3e!cT%;kHPORmp&aAMAarL>1=J|0U=x$(@%ND_f^e3guE! zFtP85ERvskN%@^gR z`jWJ6ti(i+rk0l4C{3o48E3BHb67r+xA$?aqG-#07gq;TO=G+9HPInNof*B|NNjx! z*U0t0l=~hyds8pu`mtzaUpAJ~QxjXfHW~hHPE=c!4uR`lS!$(Lm=bU_Q0=EXg7`l< zAE}Q*!pFD=rcCD*S#V=M!C2s7bz4eUda8;MWnX1}^ch`SYh^}Ki|x!_Ma9XYO2bVC z#WVFGCkkv+u0>;i$Z@PAG(~|O5dPPtVC@l!_M(ypvmzqm=6JMfe-fDmh6qyF49+*)c`Rn<)K&#x?k)Zljy9r*$ zKCNg|Nt2HK@|`>Cb9?ng%2<1$>%ChIlDc*0Ejk%b+nLGUxOvP)cZ6 z6=cW`ioMyVOHfP5LRYdy9)qNz)00ym(;Tr^e*g0YidiL0>laFf$-bc86WDyBJYJNM zRG67Us7-iQC(RgWI|4R448iU1sfMX$j`VHmj`^jT_MStQDAR7sz5|#MCk+Oa^B}#Y_mS4ZTZ{pd6Ju)CfA^?`Sp7}K@lj)@&AT4vuc0yX_ z(>8i9&g$@|_HYEQwSP)m6wl|}eCR{(MQrM(7{V(+SpYezIwg8=E2x9nagc6B#Xt|## z%?kLER&WEEY0rwU%YQk@^ATgc6K!zbmP2V=s0XX6`u{|H{Urtl^xkbj(W)MxbsODe`P}e!JD#rQ zG}y-wj5m$Qw(_;TD-hF;hZG&0gVt=ue96 zl-UScVdpdm;6-8%;f=(}?93{)t_?L-`O;0-EyiiZ$D*-eW=vR}LIn2!=_7Q)dnv8( z_MqD;g2P-CjvICz(cw)1Vka=Q}=Tqm0k_r2a{Pj!jzL!&lAQm|c=Gz7wMl_c?0^N&{&or~b1k$g}5i zb8qNQ7YIz)r$(y2c4MRUK}wE!9+_gm>!I#17kbV<`2MVSDv$ihC57YLu^VC6U5<dDVkk~nsGC3Dsj+n_@m~PR~kchA*|45Limih0M*gp zPiJ#9<){)X?i4EC#-7~kK%xTwNEjGo`#COlMgwJ!l4Uy)-4ov}yD?ea8|e~*l?`W) zxY&oyg;(+=7!si_oXVZSA!(wO@=EV#U~I#dzp~}DD|F5jrr-BrhI?I-=|RSUlZMV>oiZ4 z$N$hs&iZQ=-5yIW6aBs&>2L>DGxjNWMLTGU@iVL_1`&C8zKrpclv;_6g;4Y6txGOz>lqdVPD z;@X19({VM95kz!zod^_8-ZSi8dZ76e@ZdpgP`wRf#NSXX$c!IWiS&WC=f~7B zY$YsE$9|frPS`OsSPUzq_?(OSx6PbKljjj!1*?Pn_tmB>T2jGMQ}ft7!+XxVpHQQF z!$HgoJ$JH`Mo0q{W-$WY?YlXP!X{CjuZsB406g3?;Bbt&7eqv;>S}u8{Em1_)~BFK zW%-vMhjseu!xg;M@o5YX%v9F&An2*i6Wsd_Zo6}%np zX8gC3b36fZ;q6K0=WOnGJ7tt_ELK9Rv=QI3ODwpKLWVNEu_n9j@_by`LKiX++J`D7 zesq^0jq;sdlJe5GmtcoI)izda82lZ*vg+b7)^!42e-xsE*M%m~C9JN~)iv<7+sRbx zKK9Y9NSQcw1p+nVZE{Hpj16qb;q=_S<6S~|(!p5zQ&Ts=s*VL~uQ#1%np{6|fYaMN?i`-8Rgg+U&) zPK6Yi;3{b|^n{68BGMH$Q&zdPqrO3gzg~@!eB0!~O&1fZ3>h^QV zGaGLWKF+*4Wf@qke@WxIudn5%s{=H+jv|dlS7$-bR8u~KG%YKYyy?s3mCKd;4VAdB3Z5ptFCk^f%bW6czKAcF35Z3(#utXsg}ukZPbs(wbRnXm$`N#fcU2{)txx{(zSX|E@qO*0f}PzMoltiY zI(!BUr{+WwIatncC+~68!&_DH>WJL#wBR&(({AY#w0Ysi408VaEHx{Z-Y4B~bz;h7 z%A9-XmjBf~F{Xc0I*lYF)0!&WaJ99RJkC zxQSZ625ZI}-c&MRXyh~eY~;|Pw_k=(;2rnBPB) zu$f#Tdn@?#`1`Q%FqBB4r29uRfacE?SqeKDO2t3lp*=e<4LRZ~cPecMorBsMr~1$Ss{N?7sW? z#a_LPq%5Q10xf`L8V7kxKRw-&Vy{4kDj2;g@e1AonRX*CM9^Z6uvE??r)`w%8hZW1 z1|HtH@81RF6{Kf-UVS&f&vs86I;k=x zFfGOiGv)KZ1HjER-gz9mi_fi6{wGr;Mk?bXW4O>0nDx}SBNUGV`Ht& zzt`ZstoH&#rxaOpbC{;E4W^9NeihH{>0?JBKRRjj@bGX=oBUeCa9L$%Hl({^Wchu~ zeD_I2CN&B8=v~+^Dh!mU=F!Z8MfKX3CaL;zP}U=q%QnpgR-ez4(!+-Ul8-4m|6*oM zOC6FY^ri=+C~$^hH@(d@4q>~JQGCU=i_m@2W5*&4rlaYabZG*uzDKpg&Ld2SJEeAX z-vfWjlSP~KJ!*Cg6H3d=8e1fB3Ap#8)DC~Sk@$CCNv=zVtA8qZf!bIi+J861K5VRA zfFg_Hpm6u)3(`Z6Q-ydATk?VWCPQif=(#RXp^*&el=ryAN+*A}1>Rc}JeX2NSmG${ zql{_+JgH(q-}LH(3Ty(_(et93mLwjMxnSfTdfN$Fy5|KYSgQzV9F2~b#?sQKI~z6q zd57fTEMPW(y_P|&*zn4|mkqujvjwAu!`ykg}nZ+|VE_QhuAd z1IJC*B&#|x`aVin5^lNgh!*l!!elFzI0G3>0)qoluG7s4$jSC=VT&WH>O zh^2kNs5O`WC0oz-9AV+=srfo?;|_YIr_CDXz09Q1GWSNU=dtTCxOaPsF8&!jMaLbQ z;oN7BpASmfOjXg*UIU`A7gDSE{FCq8z&lDDjz)^Ne0Yj$v_>6qAhUB>R!ph2;P#)) zB-&BJCR-X@$VUV>sdotQ_j>kfbyBr2!s%E|dGVyys!VUY9Dxzt%tf;@2wJ=8adkxb z6?vYzQw0d4i>6A>33*wtRO(kBnP|~>;6yw&%l~;&vQ|U!UZ-Zd_yZ`yX)00bBJG{h zzkPA6vG8$(7&aL}T{?=(7B7UWXOXb>u?uFbrF%fXCxmBl+l?Z21tti6?>N%#NT^W` zfzV;0-B~W>w77O*Lv1ujy&1+Gx+7fQep?xjXzE8m_HF^Zz;AjLPVdA>?7|f3)1pgm z*;~QnXdu@&s)%`$n%ZJsyDc2?T_-rIlgBKnDm~Ri0=PTk&CYuf1wAD|P_uy}r6k!I zm6r;7o2rR~p6W7sW%oTM@AR;fH)pR5zz8_eNs4*nU5rH^H<95UcHlP5gn}Dru-TeO zJj(rjT~KnODF5DVx*Sc(94a6cLe86^(Rq%U4QfQqZ(!^$7B3r7mVe!G8%s6>fN;M9 zL67KK{dY~BfF1_0zkuwjH+vytYb)E5@eaD3XueQ-ixG_r&mjq}#JCRUQ4|@$L3hB< zLaPhx=*53lB10S|slCQ~0C%!fogM_A;geD8ltaDkd!ZQ8iHswp&>EkKnDh*2 ztGF6n?mWT4;XNxP^bNmj32x`3{hK<2d$1*5NorhFXQaM;*`UB5tNy9(3p3m_O7V*n zX`r3RBcU8+lMdFiX%n|q=8gXUHOP5{~W=loQ=nb6n2a2F}9E4 z=~G1sFGxGdh5HI5r>Yog>ooVqMAn+1jEM3UGC9%_?KR16KXlr|xQ(mz)BSSPqI2w# zb?nxA-1LgReg?=|&K_pc-+DocEuY8l(!iXa;r&+*^~=&z#A8vPVrG1m;MD*aA5PuS zAUFBU>xpSem4g;~RaQ{I;1b6EfSc-043J<&#!J{fx9B1%40u$UaFZ-7rU^Le zbu*AfnA`#mc$5)Fze2rE$~2~rPO9sO*5uWQ5hgrL_#klAq(cF&j>oRKn-&9`H5aOS z$s#;CxrGVM+f;857)FgkDXE}Y)J1I?xI++5Gz^0QVael7!P^X!o{Jp@lod56Mwl85 z=^mi(`%nLzENw!DE2kR8eb|12?MOfsGm}yb4jmXqXaG(ylt#2!T(aoL$JFiyT{MMY zpAN=u?$>4t^p#AM;BFRph3edGJMxS|Yk&5h$YDANQ+B4Gg5Z4eCB zUZ9?5w`CV%f|hwEy%42>{t!YB#K7ghUIO&^|fM4P5i?am%P?F&_KG9iD~ zphy+TR~4OEvg0o1Tu6kd^XpV;hw_%hIouYyDYECbR)j&2N3+v5NuCyZfa`YLAcQ?% z^!jmuJXdAKC^e;monlij^PB2)8Z(X&f~3+xYNZQo|3zq6GLz}|!E}NnCy5G)QNA-| zx~a36l7gsyIty)`ih{80tCG4AcYO-rlT(0UHi+)#-zFlZ`4JeOGdNB?GhH}}sDfL9 z$c>|2Un2Z)*nVE1J|{#I{>dUL_V&*Nt<8Ax2^~q|)7}~XP)f*`<%&+0COhx2xDO^-Hi%0-+jj)ZLIgxu%t;EEmV(9z48)iM!cg|LaKL zbOmJZ$uyfD$t3299|YFN3#9vtjavE|_Q~3$YAm>7As$Wjo*S403p?-qJBYfw;f-^K z24m8Ak9T$jxFtCy#jFy&c|wCxlS;S@`9lsnUY}pDYFLO6+mhf#q}TnQY$PWIxq!@C z&5#kN5fDMB&o&|IO&W~DT|vp6_D18w*prs1(H_-QzPry)@iw56;ps01BSz`6yg`u2 zyVHlFT8M^Q#$lkexersptKyK@eu&9=L6vZFHUIqt|Nj^NZ&@J7%I|J7yY7g~c-C`d z#{jsw;KzX6bm3uE`;ot;0{9}m7^L^;p|Gz6YWgF~EH^SQ+yo3%9QiJsAYgc@v#p3x zr2RGB=bXi=_ZObkUTb8?Xd6A3rDqPpnBI!R!L*+`!EJi;m(^KR=Yg4I^t2}GVqF4? z;hfw{5E1t&bUi60Mr40Px}cIUzCCJleLpg^kd>H!H!t@sYmEhHv-IWL#4^d>w7Y$x zg4fbB_~@ziR+Dw82#OtXrC6GXHXh~`wp5I)PkNWELf-3mtJcxHf3W!_1^08N*A`!? zfY6&m#>T^IttPMff^667Bw>qLG>F;me?8&s+`cj5Gd^mi&lIxm3=0=}MBAU@nXBFX zlqK|VJzV0TaJ@h0fKj@eBP^umt)SKMn(8L#;g)!=V}1|H`DMO|e$Lh>2jP~uM^}A; zujSVKI*qMP0_~K`)1#7MPIGmr=TTGKayxU&)vc0fH-pReH0=k26<1E)F76mZKCv@8Xr=O>)j@xHw*c*HrmkRZF^ z2wGWp5rIlyet2=13{KMZ+eRDVb93F-M4h^jcS)fK`)mgJc@jWnkB4+EK*e2%cJFrJ#Ws&s`TawqK;_cK@3DPFM@Ozb|YZn)ike32+J>SsJ$@?kkeZGuk}*n8DiMk6nrl6^)1OwvPI7_-fis^%Os% z+R;pA3q!BseFTzT4}KGrxwrSKvjFd$JXW+_kIRSmbMY9+lnXXrwED1hU?*ULy~Z{& zD)fRR$EInRcCpn8gXN1RjCj8}cSSYrPGmMXzUC3zhF&(=?ifJsqekpSXWrgEa-Ki9 zZcd?LY+X;@9nLE^oBX~?Z=bFsnO7dR{Z-mpF=0Ke7z=N6ph(jA-kDarD2>>2zfGu^ z#rj9l7)1e7;5g~l*olV+KArPafz_G?neQkXSUX6J4>vmXUovTVll zoRwuTRWdXc(+!gi;51FI?V~RK+BHzrmzw)s>}@l@Z5=-S%b@=}Gx;+po)il)yQ+nl zQ>bBaFMGtrg&i{Yar*uqto|h=CWGxL?%a!>ck)+WKdCZ)TV=ni0Okj+&!DfPJZm)uc?Fz*BCt@~rx)q7KlwaL+Je#E#ooHN zqKvx6-O9nat&LLdPp=V;ASfGs|41CpxOwL~tzp{CS!EgPf8kb=Rw~xa$;Qfol?`A* zD1vNr!4(*|4DEyqNA+ix`Z*;08^LyMYFm-70J!-ojfT&fM3qK11(23p$YJaYLqUpz z<=GxR4-93_3{Y%`l%kbJzlhu8>88-*v$_!5^3OII{~Hy1xdKfVcqY=nu%PwqTM*59$2VSi?)0Z4pX zASu+(avxDaNVaMd3qMkFdOS$}E&?42m{)t{OH|Byw+?8ovh$s%Azx=6eH`eO zr{Q$`VVad1Sj4(5iEPAIu?X4E2-d#mH0?@i9V@9BZP4CjZpCh(**^-_b~XX5^=F@e z!uBMh6}W?W;Kl{~z%~rf;uLiG81warr=q5VwqDulY@x|&Z&3EV0u(ft zLk;HmHc>23E!`?}VS|8oY+V8=S?xsjEg$j?*}|qN%-dJ%IB(KyHhOC8zeX#Nzr>GC z^K56|$}Ziz)eeG*#9yzuRD9lLa|wzyB?-!iqv$W+5m)#rOD8-;=ccM5 z53cPS70drFX4x9LP6FgPNeXE_3cHEzN=K6E7zh)VdIwxxW6pZR7B(tH&jhetbCH1que+(KY;vqp3aQ`tmrS5(GhH+m_@fju` z{hI5vp;YQdh4O85H-~QvPdkTz4J$cQXn3FGkC(nlJkJvc+UBx&k_0XlQGQtQJ1vyI z-wZC0Ioe9nGEDCG`&mjujRXvrm7bv~4CnF71SvL}z$OcoI?Zpda017+_}A+h!=0^| z-_ENQyK}OB>7|hGiQ6n81+m9(+x&Ks(VC+jmTgJ-56Y|k`Tf>_+M47n%tQjhmd{uTIga? zN6cE8C^pLcPH?>1O7JjO()Q6+FS}I)ttJY>=Dq6Jldf1x^o&yhfATV^IeO?@(=K5tZ_QR9ulcwR_@2%{eC&$`Celsl zKbYAHC%&S;aH2Sdq zVt=riC8pflE0uWeYtAeFQv1WUlKjJZd%e80Ios88!%z`2ZJv5`q07de(%isHr3)Lw z%ERz$ubc$*6J5o1IIee%hfdc&9+N5$na;7CR20U58({Mc*e*+#o(3!- zF>F8X#UJAgd{gJY3Q|IY{x%?+@=8@kY#`Pu-mVhA7|@(t{}3Ym<%y{2n~<2a+v~m_ znVF{hO5l;dOTl9n6TGIJHC+pOiwKZ4Q?=~s8HDI`=bK(+vm%AhZ;PF8mYd%0#D3;? zxH@yfC+78vE6X)g*O5|wEogC|oyO@67fi+ZVTDcGthf;1EyK$$yx#XoNey2O-44kJ zembA_HLQ4Vf3W2++FwbxZ@IPCZdhpoNCylgABMW5fa>LI|E<5Jsz{licn&NS*o7Qbzd zbS)x|khX#t@aY|6lKK@V#->#h9x!zZbMu?gNq`r^L;=+9*AZ$uImHOcrTCI z&uHg?2$ql3m*5-fqzIN5z0QS>lb2^a_kVsFsi~ZG+W)!G@x8m!wSW5Caq`c~*_{Ay zeb&ZeggK}4w5}9kk>sriNH~GQm&abz#U51e(Cm|jcdO%S8}NwX*az%R*7>j17h(=f z1Bc~a(|5elvro#|;6RP<=yk}>fI)!uFx0%v4 zFFo2T#w@L=JEjpOp54jEr&2ab5{Cuwn|YVKFbj>gpw@w_;pqAEpZ#R3_ElwO%BKp= z5XUyNgIODm24LbeR}N#bd^7P5PM(L2D9YuB`CQA_njL{9w4qzrL5-zN5ZMyXHqe&9 z9Ile1tAXdE0ba8e3vju8uT@>wY_;pesAPGL*7x-bt zu%u{)rt%81zsvxOWc?7}OAF=*)OBvIyMkH=L2f*$02)=%1`yV7`cV1A#9#Z+BB3-8 zL#Ag}n(DluLd8}@LAblZySsDcg&|aLe<`{wrM!ZzB6*97poN?oC}IRx>lbP;P>`A~ zd?DM6D-Q+rCB&D;?Z=&JsPlFu705UL`_ljo<1u5oW{m_&Gh&yY^F#@9DN1__*_o|X z1iUVjR@gLPvVn2Sz8D6rQ=tlB9A}cX)|g}K0;RV8t=A&wvsIUDS*F($v#;HbLoakB z3b=*Irc97hNSC~^NnDdkIgbTCOB?pwP&NIU{1v-JHtUQ;1M8|XVuH(?*Fz7oUZ!eD zz?hk812*rbx6-KQ>_={Vh);(uDN`oLdkO~e=))}vf-Rgc_ZnF0bj77fpwEnTx!AkH zPWUtf6%xg0Mx)i!;UzvdJmkEm0)CyZc1FWt>1y&FU43fG3go@@t`D;m5Hz4Sdd(2X zWQd^Nz!odBBE`O1kPK;nYglrsW0ZO2UT#qNuR2 zgmklXOCudF-Q68aNJ)3MAc%l;BjI!Vem|dY{GK0&b2yy+gPVKiHFM21*UXDm0WnDP z;=o0)v`xJXPP!H%VM?gY#0O=RDoRMpx3JI43gLNH{ohmn`WrexT9IRf%A8Y*Gu`2o!%A;XfxE!2Lq%z?_*nh}5I-n+<3?4) zXFP8M2Uu(ICy>P~QnY5ud|IP;qX@|8G_ww6r8qQBz01XFxv3YFW8gkQyeX4O#%Vnz zo2$eRYwjbs8NP!ta)ktKXt6BQS!y+p)49zJPo=Nv#)fuoI|9FIBT%whuXs%5G}B!! zKo0&*owSmGD zb7%6{S(K5_!ZKq!?M7Nu)>9dTq+T2?ZB19%9d{9?`AkOVJvo(c93`|d&om~~V6kM% z+8}(f$=8I8wwm~nXHuDV!mchL*Bhv2o=EmCuHc^V{e|7MAzK*ncl=2_XNcEQc@o~XVuRvj7ehZB6~hoZPOT*WhyB=!B$j%4P@zko_hi3NMBJld5pSYb9vn8|hX=b%L}uXmcH($9a8s zAgc(r!4Vwxwj2NcmYoqc*oN9oEX5Nz5Mlf&R*91?Pd#DL6PvCeaKOuQX-4OvCUj87 z-glW={HsY~N-0(^7M#88$uX@CMJlFy_~dVS7p8F|X2WED#*wh6i9G%Q=Tr)Eb>g6>~wZq(9rx5mm*#$o(jp%g~d+q2?LA?mnw2_ z>8Jj-Y$&fjP(}gSt<^$H1Tw)e7iT1LzI{>n;##?of8WD#>Hwu?w^}=5(T4f;UC-+Z zH7iFJ<&5dJ8H+aE%+(gvPpe$dJksZD`og$8pT#crXxDtmY#0EpZ#=VDYHzGCX<1D> z54-IzWO`35oU?z#B)XMBuPl%ZVW8z-8C`GQcy?KZrvqFlS~>HCkU}5^W)ztR&1E^K z6)2wTNpAkugalF)xbkljI*3GAEZ0rXTLkoNu{N;6sHV);iqP*A&MMVVe^JdMXKUd| z&a%U=hbNNJAtx4q^8*g?-_8#uw{p{rg=q3>08fO#Z>i{AQP|4h%r7$hg)X*7Rj=+) z_6YbmsC4<1!+`c!V`HM=z77^|du_lgGW17z`>LH!_!n8TR)zf^J=gZQ3+UTs1pDPT ziV@?nO2pu}T+<21C6oJ%cOP&+Q@qBH$$T%^HDaf~b_;9ws3P9oVK%FeAS#uR^ZE~L z@_gnw9lM1j)_j?r)c*XJHxg9s2z=<2&nf#t)=!o9MF_W_4iPj)7+Eq=!dwp1O17v3 zsMg7EpFF6+CxB%DvbLQCDq&m?JN&QtcCI|qh3}Q+(l_N&rgC<4MC#u)zDhYCf9;@E z$rtDSkKrWhCGv625}&=tF?r!IUGsqsBAIKG$tU7Ay*rE+!udp^_#0g7OG}=1_YUZ! z-FQ%(vx}S$X7nl4k{>p>f66*KysrZW0K)(W`CFjT#{|I7Q$^hi3a;We+oNVqxAlueF0U(p z_=#Z8*a~O8i3^Z+eorJ(x3WOkq9#;wlecV8caRQEajWlfsHG)~-WG!t@{PL8G@Ujp zIMX-{zq>v-qxRHptX6QAGfqgkNSpUs+atp*MeJal6p)1scan<~Tn1Lon1L)OU3o>? z|CEv8j*2>r-;&`zk0@rjdltQuDp4{frW5HgYCUhW%VRcTz3N*5Iz&Y6-X&6{3*4+h z;xS$~u563gj0!h(3q?h53u89+5$j8BDCrk%_jLZ8Y9PZI?LK%F-~P&?wW3$Na6I^< zPzHe3hFK#jqC~8CCxi}4h0+w-b_L$WOPsQ~zeOsYi$p#@6l(D4b0RwA{{W6%QbFHG9l$R?9Mhz zm7Xp4{yEH+PVb**iqszrz00rI|Ltr$SLxl-UOu)*Q)WWk){ z-O6SHo)GX==wyD<|LY2DTfS)~B@T^Fj?p01-6f9+}r^-ND-C-z3v&H83QNR6@~^o1%bL2?*}?6nllk}DOGpDu<)I<#26?dQp&+}!$# zPFB=uVeffwBh(?Mo zidv|K{cojremOZNS;Ipub%*NhWM#Kt+Vtec#(~U}Jm|_&#)N+H>!pjnjM)kDp`cRW zb&E*+p=->Rw+X2!d10OD>9WmeJjf*^9fx555)>8l7o&M|DItx*Z~IwYPP+8r9cK?G z))s~o6B6E5OvH-!R+rBNVS8q!oKZe0%JoQJD!M6RYKgQSKTpGfDQw8{#^OiV;USJn4V4109hiv_xIF%)%ODNKGo)w@=Sn}$)eiz_iW-3ny zVv7wLDKtvIuAK-n^(RFgNE~0)7Et@nnt;+wSNiByZ6Ap=&Dd-Inntfz+cBtoy}LI1 z#=V6!?BZuaBh6s7miv<753@9Hw?e1XvyADoKXqgwuQP-YtH<7j)N`JbJgt$tBEkJv z(?WlsuN5vc?IOi$yEGvmO0C#+T$zz?5a5fG=VPkp&wW#$C!EWd!e{1s*yiba+4!Z!^^$2{ zlCi^tH@1pd)5N)e_BAdO@P(SRD-bGknPVqaZa&RuQG(7-_FGMUP{f) z(;Ax7oOYOhDr=Mzw9GMft28!I=aDy+LAGMUYDUC6eQcaIhur1`h{C79awhLcA=`1o zw74tc8MZ;C`L$n)+bVNkOEdonjCz)bKLUxx+11jCCqlePg}-M-*9y$?(g^sS;&-y%;_HSb%Z4%Ztvb&1n^R%+0Pa2Zbn9WN zT=Ma~6s>h9V!N#4t716iug!k~2tl zLB?XAWsQnhZZS0@diJU9g<+6S-Q~4HXMz#4CTB)xgUY5dsKo!;|3Ko2+FLnR))-Mcj_)_~q#d|e+v)Uo zOu5s1TGGfzOnHR&S_%^8YEpz6gj~fqNRM2nD?Q@{aPsr1196#8NOP!M`!AKw=qs;2 zK|*57lg-WQkqidMpSX^dtu-ht&5v~6V{+P`jx$6*rBrO!_*l**iPSvzHrI7CQCsC# z{Wh**jX50QX9SyMKK!M1Nnr67)TG{?-FM?irN9n*8R_^vX{Av*u_+QMMf%Mm+Lkdo z;8-Wm$sRZL*Ceqa$3?T*#cfDIa!8TCui=w&CeL)H8;tixsUs7SC;UNk6U}UZQ*3Nn zPZS=kxzU_%PPFw~?o39{gtZ8<6+p`amS{SEjCN4M;?A2)l+9i>u45***VZb`vLov? zUuP?#5IlABn_MpLrDU|);5Z0rIw^LztWA&~ujTGm8fuz6h~wR+rl;L-Hl?G*hQ!8v zmS-HPlPb>M23jw=@{ZQHD3d|kg}Vp_ z)aJBqxktH0F9P%>`r#-l14*Y{+gd?(SgX^w_;vG|q5IP;nTAA)^ z9A&wG%^w)naCW7Bs$4!Zpvj{W6>j19q59zm6Y}J&`|AA@RL@n^T^)y!N`{J#8~W4t z@qMA!3*yJa$)ZVEe)yysWl*ydWjXSU9J`}TOw+}CGK0fS@!3dMZf1X}wwnY=g2eYk{p zf;qsG?X;KU>R9>QzsS=da}|@bC67Yx0RN`EOl=(Obzq-`!wu8I*-G#ib7#2`Migti z?0D-uzJ5qK16K8VD$HV&=7YZX(z!UqR4ak(Md%{4ZBe0}dAw_krV+&?XKv3G$!~h# zRnKFI#C|1?{Sg}-Pb4sP8IxdW{)%|K*)p8%H6_e4s*DM}M@n)#@B`A?onnef<~O?K zqxqq$k%Qw@wue&SH;IK-d-8Kf?QAvGPtCJ(PT~DxJ=r`kqlNtTP47L7kYImjg2S#Y zk=$R3&p+Ds32pDXDxrD+!cRaE)l>B>jH9|`HE7Q`H+%T)CjSs0d8#yF$9wpcXl$j! zuQuAYF)JcZF3rjf?G%g*>CL3zmt;&618!v|1?=K{smbXpOCrILed}dC1L34p19?@) zXCd_wDCdEn)D-qjUS@~^Bkp+u`Ib&zv^8bFoj7oetjmCS%4yWdVu)f?m|^$`Lm%@4 zhs@fd?Y{V&7){2^cUQ>I1B)2{oexVV^L?*oN)7J2Pt|?hp2oB6Ob+xsR^M8h&JQX# zF%?eLA6$E~zP~%!&#L9J1A7bnol2-%3y%2SBG?GB*>=~J}pXSWiX-n&UWLz;)#R6tYE(vBqh2Ds^;}4^W(#~GKPWUdirbXiX7ewgHd@o z^SFa?{(~xbWey`6kJhGwa5H%jdj&32p1Z|&t6}9CJRb2VC;JmF(al7`7_pC`43&W} z`dpLbmVxY{808tP=9_n8^EiT>LC@MNXje%5JYHI>Qo{DbVq1bZfHmaf)*-$PGdgjP zzi~FVZgUp(lo&}IZLTTPxt;8H{)jqCu|((!)Z|m?^{K5ZqrFGJLItjNTrTs?#~h2h z5((67m$?597VyU9@s&o_xH=CoWl?;=ud9oQh!b>IPi4I9*T zWOI=0e!(=&kK=T}ei3?SbQr^upxRtmq!5K4l0_}qb)8G~Z6>lDvk3%8O)ux$?T$M; z1KolhEnx&!4ZdJ2#iGpSmK#PzBEEz6CLl90uAr;}hby zcbIBl#wY_zxRW4qx!sD~k$s?@Y<8kE%qtY)*LJS%i%dH%o9AYywFAjJ1AAu_12(FJ zEF0BHXb9b4Df(d8&5FO@)X&Cd9<@sUewsq4M*s~)nGf7SzPv)(OlT*3B(NBA&FXkC z@lGOYqSYOt=4b1LPl73u{T3Nx@sXqY`4)K$qFB1BC_~*L@cE7wP1RqoaMea0ZPb1X z(Qie!&*4=>ok6%1S^a6%P*rBe*P#e(6m&H|NK`{U1q90eF$cz%=!v4nB2)7W+j|05`y4MV?f#l~wk5ZuD zX~x0)y(~zmE?x!J|HP0^q^M272$9HmhM)Y}a*y0d@PwE9SKJ8SnB!r(Q%} zJ=_c;{Y12-kVCzVd*r-xk*2-%7J?SlbQ>DVC1e#gd3PO1DA#5bOAbUPq?dKSlab+*ptMR2^m{xgx{zeC)A zL4L?Ch~rC1jE>PiRqRSzCZ&9XanNL!Z3@YUzPR5tg53RHg+ zZH~4%U%*5}MSwWN0;V_Z;9MbsZ6PC{Ccu}R15D^exOcBS6IA!7gFPHy2~;nL)~t%w z%|0b7WJS$Vu1nJq+t)GFgJ~Z@y?h=dEl3*RH)+R`>7x7tAy;C5jl% z-3`UzrDw@~+cEyr1eOn~DwKc4G5QoyhUr*3HcQt6>mWhjAq0PtcbZjR=}ooStIB0qL#*<3_2EiF=Yq$jR$cQZ4CPn|P`^~t(IVV0>; zECorMh5lhtChc8i_R6 z^S!?OfY@n#6cLj;X;lU_XuIa=FnrLoi#j{8zW#B@ME55)_iEHFVrVvhCG$>HiupMg z;ols)Gr6lE`QH12Ea_p$hkJ^{u?RkwTDK)g+3}AC z1hjFu#;&P&=Z8;hAT_xH9f{Zi#VV^{W`8nE2SkXeQSWTiO3@M zf-@GmLLLVO5kIRgc~nJE$DRuj7lVipO z^$K(n^)#MSqKum6B%}%H1aVx1J`dY~Pym7AQrz6~1*kK@mG6*{u&BrSt0m32jHeAT z0+eu(CtWks`K6e8`1qVMR!N~M#!W&nN6PPPA!Uq68V34xzZcRz zYIM6p+aP0)m}PW3p4$4)-+W#E*3VzQ*`S$8oAUChyyf=rlOqkxan`|n zk~#1*Wr8LUUa;a=Y$q0iJ^6i7@;h3ZOle_}41YGBTe36Dg7sQqmgzLTPHj$QuY_Cb zS8b@CB*~_3py?TsiuVv*9}fWdRyTRAo>t_Mlq6uRB71OxFiDW(5!0dVl&%H%b2&tg!yhlFMyj=$O=I?@JEpCQ=Hj+R-GD_=xE_@#k|n;n*x_T!?o#V^_BJ9YU;z3 z%;-$}A}z?iRYzw1x)pGa#U(+i>Lg`|h5?GQcN_m1HDAZ_3H8maAt=q3 z#Kj$>0(}}6hmmK~FQ-Q#OOS*bU6EFm_`~N(t6#NCV~67F2n%PIhv$0w?4s1E5~a{0 z^lh>a64~|g*oZrt<QT$rVmfy~sF0Wnu-ySAdM@LT{i2WFF}oMhc)7-ewj^ry4Be0Wt}#?=a+oC(3~>*2*N80Z_7mA5SIL2w z`-vd%qtNF4xyxFgF9K`<13S4iv_r%&^|FvQyPd+HksR10A;hnAg2iTaFPw+721Zjs zTAwnXPxZBgmoSaVm7 z?SOK8rupW#UVrp@Oo{oLS;Bc+%U|)q>Dsv})pED|H=$LtEXAHgAq}#T?xfHMn)C`9 z%r2fGwD08!2eBHcXkE}xX!7!1b z*K_&Plhi)1C@^5pLP5Fuu-&5#@Q5%>J?*1Fqa>F9$^(gF52#+> zwoNuWNa4fUKl3-WY%y~qWX~I2o5c0OxK;G~$gbsvorSg;u%xI+I=UO@VnmlKH`Wu4 zrJ)Kh=!UlkOoy@G$fkPW`I6?dIy{l0L|eo}V9al+Am8%c4oSTec5m@CF+h_a>U2~g zm)5y@5>PiRA#;HXSp1$wt6?;D6KVPzeG!cxg4v5dPP=x5uCK7Ra`gyS-3lUlU{!yX zb(OZOWlh_W*wO45yt{n@G#Y}dAWKo|DG59|OCOvMzsxcKQk-p@YD#>GJFVj+>i z-UC^#po>mFi0W4@df|AqodpYSA^t$IsIlF%?Q;F6%>$TI`HDi13Hy4D3fnPZFD3A@ zf-xmOMsD_~ng0HoE-LABw-DkgQSuJirAhSVg$kc?drUWZji~VL;}EEw%l2@v1SWt_ z{xS<%i~^YhkjSVH-2qd9^9Z&CJP$J4v8GM|^RphMDE1U4e3mFi*hfZ&%4ywn$okY{ zAZ*g_Xhr_dgFGn&L!Fybu06tTq%F_#nnAGNo;+nv5=w#uWq8wg$j@fbUB#lwo=i^q zeQ>-KrdaaeZvYl8{$xrZy>O~ih@pFTK&jj9*#vQy4R9}kK6F)lZ0blCD=DR+%64P@ z?nnPlKsZ*+*u&eq$jkn`lQUT*PgO6t?B zvAh;M1_4$+>}L_-XCx0IX7QMb^N86@oKHWNI2vnHI3UT8d55-$#-b2TW{e@50{Dih z*VEh?mamQ~tOb~=aDea3&1el$Q=SkiNeVaYtG;53}Cthta6< zV8cKye5(L8Q*XIwaZ}>~Kn{sDU-gAUgcDU!crzR1cfoU)eXF3+n^Pu1bamBw~k?a4P>J+i|lcst0!$3n<&~ zLau&QhDf$2aU}g0adkx+g9rlz^>1~_fQSM(4K`ef8lV6GWz;dSdSw#Kb4BVySlyNf zH3NR7Rzj}kV}Hhy*Ex)=cywh2ec9of{;o;N)f)4-&^@I!B((-xQ%i%!Pa-Yz@NHpr zrIy!RtZZudnHtEBckEm6lrS=b0VQZ+mtA0Gk8f2P=si(kmWIHt>@;k^{;4@us0@4N zybnTw)78lY#+eVvpx?7xoaB*?#F~{EP5x>W!-y2))cImG1l~9%Je_;V$fpxgER6F1 z{6Xp|8g_SkxWA1dSEi}U(5G=KmkFc~Rz13#DDeI)KgFQG=xeA_;-NW~sU!8Xkv>OF zBiTa50A__5_6&Fdgd2eV8%vP6BOe^CQZq=y#<%zEgi|MaRRIxZ$T-;}Yq2cTZq=uG z-wefU_54nbCs+O1@8sPT>g=l1c)Mft1SHF@$*DYHZas~!C~0)1vPlx|FzI=TPp|v~ z5qtA8%WTjsVp+(WZEy-ajJvFC21c1&Q0+_SGRfu*h+7jgqv_!T&6!8|Iobrf)o=J3 zB8ahxNMXw5@egw+Q`x?7!9-dWAWyH)6kJIbluJzpBNs1GPs!fd(?x&vz!@ z0lQ5VCi7h_7;>@<+Fv|W45;<$ ziw(Tz2D%H`1r%oy_0;w`j&I4#Uv)@Cj>zrMF1+cvB-mN{oa{L&mjk^U5G}HZCMmqh z;FA-ql>NE(=CVHnB3IEk!{T}a65U^?LWG5Cx#hdM<+_@@lAa0coC777k9ty*oF#crsMh`fOU+R#4 z{jFPAH+wO(Sah>Dc%e)D={iucXj!G!4DQC&jt%PJH`V#wDl!Pz z(!5#lVo~Tnh!Sok%kalH;6{%QWma6^L({|{0Y|S|PHl7U`Vf^Cd^6@Zw&~b8ZD#V( zq2EK`?m?nE<;UYy6+7WF;zucaZ0ah}O#SX|hX?BVz88A~eA|AZ5bSPqb^B&K?WlQ* z(T~TvdpPL>(?eBMH=0Z(P+A>4m4dihdl>4{k9{@=1r=!9Y2LimbzK& zxhURyR&Eb@z`>G?ocx}vjx+Xa@)Y&G_lKObx}r-2&?{gw!%FD({7g6(+)E_OWh7aPTRd*j6Kr`I>JY7GJXo-{G(R>5K?|tXIIglF%EltC$h}_MC4f!9C>M1&lCDtJhLmzqJ38l~3X z{xj9kem5^GCyz=`htTd!_25i(|E#xhf;<=b3`93FA7$$CMcuv|w*()+&`x94vMw3# z9o0vdcMbOxg`E|6+#f#9O=^-GYE*K+olo+{sN%w%0uy4H?W*CPh{`PO%ZS1R7^CUp zo(l93SGk6Qdh%X4AWagsy}t+y;LaBdE5u2sL4q#uu1FKNmKDs$Q=39~5()vZ$K`K! z_sUO)gn-7I$FbJWcf+`^>me*y%mM##@MnUlVX|Mhd`q_qQ9-Ox*L^}ze}*(nUYdz~ z)8vX`D95h@AZ2wpkAv4JzuJO ze?~yu?_OcXDj5iEKftK$#NoV?C)2p>7DXsKf2o-(+T-C!jFRA*#b})H_x0DcYp|@N z-loo?%|h$VAq#W6fFE{-sQVtRtv}obnw1aax@_&{e{{Na5t&U}V$VN@>k^sAE)=n6 zO(h9Xd(X4;8c-CX{pE%E#S_0$$jkQz%gbZ<&-<}{ukuu3shxmNT1671&(6=;&7M8U z8`#Ff)aAzKjD5Eq=U`7{Ud(?U1)49Xy|PE;Q5vjoLt~y>rxsiehk2h|(gAfF_J4pt zr7p6;ZVRsTGz4FT<$;?VGt4MqI^VOlkN9yE9cPaGBp6K+GZz|*S`1T8Mb@vD@TwhvG}xzEo@TB=6l5VW^=Q=|TLnghKW zBgU47`sVmmTne{It<5|bshd3Yzd0wsu-`oIdDjw)%;>8_bt0wybPNHCS=;-prD?DZ zu)B9HWgFCVAzUE80~+DzOe~@dGkVk0AEd}ST?T!0um~>04)$?$5-xz+gCle1vNV#& z9a~VuR@ZpEqD7=6#A=M5_4Vg#qZ@R~o*H@3+t#=h%6cHsfZ&1_F;JtVI?KHxMxngmbo*u^B`3*Gh+47Oym-0%LtRVvmOLkqN1oU$}3jr=3*kn`SU|TnTJktC;NP2!2 zG~RR5o0DT_Qn>R#c3t60py$H*7wFUeLIRNI3+o_2ogy;{%s#Fz@-`f!saT6A&-)CT z6F;XX?Z!R)xm#R*Uu?7aZoZ{4j5)~#eHJ5!AVVd@w}PWe!_Q@sG#NL2BC(=#15<7ToD*Hu+ zQ67c1JiiO=lC{JpH%Z%i;%d0lSrc9!5p&k&4Rf1HOc&DeXvj4(Nb#|=9vi^sY6`h>#J-TL9!1PC~1CdD0Wi3LZr!fbt$9Q+N>#risVli<9ekclTm z{ttjo%#6)v=j4_aV7DCrh`O7sVK249KEkF8p!(i;S6l~KBbvK6*0kbEXxGDPdGS)i zJ5fT*iWH>gn6>g&BG)V#n)&D~CdfyQQ1$0E!d}B|cSr0t>e7Y)SBI(4L5}Yc@_EZO zDmr6T!V7V8v~FqYsTojFFN$>immTxhU_xF4DS{1G0n za=^X)Fxi>zihk{sF=L3ujBX5|&PdM@cuJs_z8dCA0aOG3=m40L(1v)5EmDfj&a_ix zo$>Km{JyAs$D9*uGgK>2i64?h#&NKD)~2RGMrc-BPq)`*yS=a)P$ z>5gcM8_7RIBL<*);s*2=C=&8&;x!XT`5rx4TsFP@OTrSzwQ!rh4@B{e!}02d%@_(3uqJc zAG%nzoMef=568fkX^h3xa_OEh_tZ@LG39|ST zNaY+xQuENxMkk_g+fU<4kD)HGBr8~E^Oub_a#;MC))spF%Y$O|g-^V=9&1O71DL6g z9e?$C5>pNhh@HI4#q2bKz4X+af2}>U4dONG@%|k-&(5|?Ii^ZW|231abT&}XOiFEJ zdzMnqEd>4l)`3PxPAVngC|UMxEu+Lx5V4(UIxri9M-@%CuZYs7+EYsqF}}gsos=A} zCeJd)(E-4QZ-tPuTd2{I9MMWNN7rY$ zniHNyTU#iocLdL;#V*gV5F?VAjPH}s-K8T1o7HC>$14=qu|_uAO}~e#A6{j6J$hF{ zvwv0%abMy;#d~`~>@R+aC$yCTeAmdzFvuEs?!5J|Ol}E~885ut@!^0cE%MPve{QxI zST&;H>kB@s_W2!bd!qO#0+*t|4m9akCkZ~4ie4u9iX}cE{UTgi{2WQ;&&JS*Vc*~J z>Csqk@-%?~GKRd1Vz`zr+uQ&8XA;lN@++tNVv7rQYej%gay~&Hd~cGgCU|MrPAl^J zyo#zzzj0Zg|A<%=Zl8{Atd-mb}_bhOL-jMw#>{4 zJ}@C&HDki2%q00o#TrH=8h+5m*lA)=<#WR4L;QW-Q)twQcoT#=?suOp(Lz$qg<@-@JcY~q|g+cUf za`>67h40LDDc!``oC8Z2cRETk$VR|4&&j--Y|Z`na=JPqNuz|#9BKG$3O^C`OJmnIxJ zP{kmx)6#EVr&d(YF#G#o0F5LaD!EgCwmVN_W1l^uqg)Rh`A`Sm`yVgtd`?qeP)HJO z5&*4;Cq+==47=7VuRlXy{xob)R(`t;d>NIXUJap<)XUT1;>yy?hm_N7zPT`ca~a0( z{S>jYP4|cFUoq9f-A{Vo!}H}kbtK)r`HuHoAsSKM9vmJtcc&obIIi~DiZ{F=+F6$3A92Pw{4&lKAzd>%Hd zSz4H4qp)<|4;_w0#kCxf|I$jfLOwugKb#NmmL)EER>!iL1eBK@i9(#Q)TgXr2JVIR>F1HN8F1c=raU7z9hg<5|_gfWC3eR9U%`bg1o{MW<=NQy7{ zA@(@b1%3~w^A(;8N?do|tNUK>Pk-R5L0L%(&YQTOs>*-zACu9Dl+5 zm$JN{HG>}?n0>mGAe+c15MbGi)5s978wLQ3Z47T7&Uuc;9KCw2T(bJ_@i@8mV0Z@U zuRt5cSz12M1yPDHfy?oXKQV@>-X?=9^~0+Q3lgqd_jUxQd*b|6riV*4m+4N?X{XWq z&A4p3e0?TQUU$j!dUvO_4oF=^#wPJLo)teuZH}}Tr{0C>qNc_3BKsB9q@HG}f9rlG zQLrcTum;W-^efI1EkjO_nSwXk*@85J&uq(IAB@0RqHcBxix&7pFu0Ky>HV~lL1OP7 zKkDm~ZITXAp%~4VghPGiV*1H9th-LiaXIJWW|_HZ{U!jtBlTw456yoCS81aY6tOIte=H_|)#(D-gsO(o z#u6w@RpecKyVU8AlK9rIquHBNySo<7Nc7J=f8C8=3rRp@XlxevT(zHL!P8icus)9r z2Xlj-o8**ORz@uwA}sKxWzbY7b>G`VBFd-RZ{a}H9fMO7hX=;X_Sz#TZkeu9A!r)( zXdq7`3uiL0hVoC2zEma7Q=H+?p(CHu)ygYuY)8wj$2hmXzhb%{r$<=OlUxwN(@4N| zK$`S3v={nKK#`ZPBSagwzN?w+5!y+qZK8ltMP6m8+L1T$R6rzHt$hD*|FG{1Re6JY zNPze!rOHNtXg>=6-a!hi#C<1YVa3*sEJ#5py=bqX`^F94ehn3?D2FgqVy(WZ8;jnh z^vzqpdm3?y)egD)Uiv~3`)@ePfwuJV-87m?dR-H3Pkxy~CaON)iYG_}nD<4YG+oIg z4A%0pBGz>y>z>O(|)aIDeN=B4k1QuGvKcuYfbH-7`-JseVG28b6o1SXjqew^j_(XKin z-|O;osan26f>1r%drbiM)L#tYSxjB6fTS~8O-fr*MYqoQd%a>$umR-UQnE-kZkAAx zOp;CxGme=QGp|aGpd1j5Y7oLB56HAXM$4(v!hi{QX<1|v;|xxHlHUX#W(vjAmmRgA z(%dwPu)Hd&hg*$Q?z@wX^UiaAl#gx|u$0RuaBMC=a|2DTnFQcw z)7#u%T%OH{Wc)xYaLK^%5ngOnIER-!JYUt9oA*{KnD2qs5GY)6i^Ap(D)|x z^7*Q)!5{qplK37mb=}dGE#F97Uv?6YurnJ)oo{yKlsl>pez)>@tcM5FE|q0)JD^s3A&PJ>u`xrFkfWz-8oEh zEoerSBo5TSIdP72__0y&X{gg&s;!!wsjOn86dT)o ze~WykhpUabbJPEtXF5!DyM#jZM8ajnzj)1l3hJX_k?Luh4xThInS=bu$mx~yX01|y zh=%WJetYNTUyrtm?!jW0`^Tv6tYKY)+9~;u7-mZhGNfyYbZj5UfOo`sJV6l5*e z0fzQ!FIqbaeFODIn8R(;;L0R&-EFn8Q{9Y-)B+7&jj zktyvrsDblSbBdI?v6iFY$ELJgPsZq~#;ELJ`F;{pw0JqF4p;d%^=mm3!j*=}wrQ+} zX8~gJw#R)MQL&;Y7OxaYLSAiC@E_Aq6Up!>FNbU1Hst`jsJ}_Eel23Lje}+S6|WuH z_!1SuFLMK`;9PtUPZ9;6y?Q%Da!!MtK5X_^J=5x9%$t4`V^}d^6#kC!*LE7v&MZxqm@Kf9 zmO;})qkoR0eGHq~7L?&>0!yM__)n{>u=PfT;%Uqq@*d?1x*H&$Eqf6=VRJK}?V=Y- z72uc#4wLp4=g)zUod+7_YJ@9hqDN}gRhwDoNkdvCV9Lwg4QS1@S;{eoB$hi|=iE5_ z#;8ZFH;q-yCP+m9uj^(S-=gW_`~9+b6-~_{i;RhRDcLX+tvQ8cY|JLvjuWc^6n=lZ zB2Z$lz@4}<=&Ezr9OSjE9`b$akAB~J{^BfXxvC|=QPlm|pHO#HG4o&4d%z_~5l^9} zgdD`=1AU#Ql+qtq{sC^E`XRkgLeL3NkBMkg;4dRPjcM)&acy%(pHoMaG5ND;i%O<( z)R6@^t;vdP>C_7IWOQ`kiXK?KNn>s4XGT*;T`|8rkWjpi(Kt`&PTYfctQd>aA44Gw zEhbff?6yM%BU5shGIX)6`^{m|*B7RgN!(akiyEVkGW=h>>*6B;zlvDq7~`(-dlcuA zb`|T_SPI=#8zp`$NJNGwK^X_(S#5yaiT)^2nyR19&;I#^%Im&V<5-oJEmr3Xo!7Z@ z!snp>?I5qyu+mglZw+)o5?w#?ic}4O1c?3M8QF%K_mxBX(Xh> zp?gq3Kthy3X(`D8k?tC#K}urXKF{~Q>sxzk)1U0j|E?>}^SBtBvHHL1K|I4_o`L>} ztbgh4C&am%Xv`-l>yB9*HEy_fEl5FCpfshRvWQh0>+*vx{JkD-%I8PCFd>QoFk_aG zidW%&)NRmjtOnXC|M)0NDUV6@RvUA9Q!|%{cTijXU+F!F+<5No*d$VFqhgSTJo5%Z z@T#sNTgp+Sj_-zWR3?ND=o|)k#N<>o0t9KqzSt0)e(XlH&7{m1sFn3;?223U3M;2u zD~WNZ;}=p_#yKZL<_1AyGtPeo)#cZm_ILm+O-J5=&SolnC$+)jRxMBBU&T=Ia*fkf zRXM%<$1%Fgh^2x5mAT;j{;iKStIFv>-#Xxb2&R_$5Ocj2-^UcRL`R60KOFz=s7qeK#?O2I28eT3>Y*#OJ;<)`PV;tSD!m zR7k9iE9+_~Be7T`e!`1j?{~~`U7DtdGA?3DVhYZV8a+hsJ>`s+;fWFn;S4ox4n8yE z4^Uff%=Y-CHEnU1H7fF}aZK4;G5yf0E`KP>u8xqjF_fw;GuJ5M`Nv_oq7%K}1Z9KQ z&fa33aBaD|_iyfWgj#Ftb0=Qc^6oH{1M&X9_?bVP_9GZtjK|)*9 zXxYWUL|R#qVc+esUU4&EMf3-K&0-W@6V_lmout5~F_jN^o>kjl2ch)s@FXeRY<&mY z&Bk#X2pP=OE5>;pWP=Jtr6-M1<7r>s74^L5T$HbsEzR9M3~GDGkA+ApbU+PuNebmXMt+2dHA>LJoqyj2l5E`?zCTKNJfD3j4Ih}kT32QH@cf*vk8pJF zulQIPB&Fvr@9w9B-H609>^8Me>Jw&ZpGOtPkYOe4_kJXr#3ONhXBxL-1;5=sf+%6u0a~VYhtsaoPBFytu^k z;?w>Wc)9yF?PW~A@e^U#g-MDyXk%eglMhkwOQ*Go717YNw31PvwibD>D8*9(4=;t9 z2ZgbR{YeV0J=oqeXTPCcyFQR#4Y2syac8AnBKgI6ex=<7frFY_>@zk^J;4@RZkT5# z=G$3Z!56jCSdAg9g9kPkiR2T0-|oO9RQ?C4(Go35OfOsOZ!H_m_&u3E&bA6`c^}+c zN({qrQ1!!#3lmQ#L`S~$30I7n{^HhR<0+kkdilw7*%kPrm3A zh5sdiT+%uQLrur4!TU?Vg9_E96^X;({RyR}9t%8Cf6DJ|(ZG+XP;@@!49JPmDaSj7 z>`HZ(4oTcSr4BND70`R03Gi6YiOm0Cy|nOQLLcMP(r^`vQ}vv=;jtNaXg8Cvv=P_RD_x+J;Nz=sV?A=hBOk#T8;*oSf5COXtnri+2 zBXc^VK*?*MghTFsNDCbMshm82KNt40OP8=jB|1pVouO6WQ53->9<$B9z|pfBeD06X zc~b0oPV6z{!>AS@ct;CGr{<0*uOTztY zn#0QF9un9(;<1@hF&MHwYQCk9_jhr>No5&jx+=#Ph11<0#)NZ^wWV|%cc=6b0$s$> zU*dMBoAG0QVlu8Jak&GJJ3wVU^kPlxW9IkmdsFsIX}w&%SRZRm=ZQDW=F*(WRw@

    _-HRGf`T-(1>`_0dP?`+of1OsHe@Ox;N-6+F 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 Su{2NM2&zw|(|_Z??%x5elXNx! literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json new file mode 100644 index 000000000..86e635c39 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "iPhone 11 Pro _ X - 1 (1).pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf b/Mastodon/Resources/Assets.xcassets/Settings/appearance.light.imageset/iPhone 11 Pro _ X - 1 (1).pdf new file mode 100644 index 0000000000000000000000000000000000000000..2b8b869b03394d1a41dcb65fe04a637a2d00f88b GIT binary patch literal 463573 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

    p1ZRwK7v+?hcj)0!$Ug`PWj3L&;m3tHpCgupi(b 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%0ABiWzIn{9>PV#eE!J@f7^ilq*h6#Zo6FJq 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%0Aiu= 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 z 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 G$A1EU=#!oR literal 0 HcmV?d00001 diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..523b0748c86e7d8534ee845c945a235f40857856 GIT binary patch literal 6148 zcmeHKyG{c^3>-s>NHi%aDE14G_=8gvz92t9q@V~XT%;sWU&n7Vetev+yiSg;6i4lM}V>pC<%o4=r0b*A;Br-y=EG1^C)retP&UmZ5u5d`qa##%? zRySKsC>FQ#{1)l3E>Ttth=DN!*STDH|9_() + + var triggerMenu: UIMenu { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + let menu = UIMenu( + image: UIImage(systemName: "escape"), + identifier: nil, + options: .displayInline, + children: [ + UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in + self?.updateTrigger(by: anyone) + }, + UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follower) + }, + UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in + self?.updateTrigger(by: follow) + }, + UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in + self?.updateTrigger(by: noOne) + }, + ].reversed() + ) + return menu + } + + lazy var notifySectionHeader: UIView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + view.axis = .horizontal + view.alignment = .fill + view.distribution = .equalSpacing + view.spacing = 4 + + let notifyLabel = UILabel() + notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + notifyLabel.textColor = Asset.Colors.Label.primary.color + notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title + view.addArrangedSubview(notifyLabel) + view.addArrangedSubview(whoButton) + return view + }() + + lazy var whoButton: UIButton = { + let whoButton = UIButton(type: .roundedRect) + whoButton.menu = triggerMenu + whoButton.showsMenuAsPrimaryAction = true + whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) + whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } + whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + whoButton.layer.cornerRadius = 10 + whoButton.clipsToBounds = true + return whoButton + }() + + lazy var tableView: UITableView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) + let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.rowHeight = UITableView.automaticDimension + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + return tableView + }() + + lazy var footerView: UIView = { + // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) + let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + view.axis = .vertical + view.alignment = .center + + let label = ActiveLabel(style: .default) + label.textAlignment = .center + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") + label.delegate = self + + view.addArrangedSubview(label) + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + bindViewModel() + + viewModel.viewDidLoad.send() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let footerView = self.tableView.tableFooterView else { + return + } + + let width = self.tableView.bounds.size.width + let size = footerView.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) + if footerView.frame.size.height != size.height { + footerView.frame.size.height = size.height + self.tableView.tableFooterView = footerView + } + } + + // MAKR: - Private methods + private func bindViewModel() { + let input = SettingsViewModel.Input() + _ = viewModel.transform(input: input) + } + + private func setupView() { + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + setupNavigation() + setupTableView() + + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupNavigation() { + navigationController?.navigationBar.prefersLargeTitles = true + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.title = L10n.Scene.Settings.title + + let barAppearance = UINavigationBarAppearance() + barAppearance.configureWithDefaultBackground() + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + private func setupTableView() { + viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in + switch item { + case .apperance(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .notification(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item, delegate: self) + return cell + case .boringZone(let item), .spicyZone(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { + assertionFailure() + return nil + } + cell.update(with: item) + return cell + } + }) + + tableView.tableFooterView = footerView + } + + func signout() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + context.authenticationService.signOutMastodonUser( + domain: activeMastodonAuthenticationBox.domain, + userID: activeMastodonAuthenticationBox.userID + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .failure(let error): + assertionFailure(error.localizedDescription) + case .success(let isSignOut): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sign out %s", ((#file as NSString).lastPathComponent), #line, #function, isSignOut ? "success" : "fail") + guard isSignOut else { return } + self.coordinator.setup() + self.coordinator.setupOnboardingIfNeeds(animated: true) + } + } + .store(in: &disposeBag) + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } +} + +extension SettingsViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let sections = viewModel.dataSource.snapshot().sectionIdentifiers + guard section < sections.count else { return nil } + let sectionData = sections[section] + + if section == 1 { + let header = SettingsSectionHeader( + frame: CGRect(x: 0, y: 0, width: 375, height: 66), + customView: notifySectionHeader) + header.update(title: sectionData.title) + + if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { + whoButton.setTitle(trigger, for: .normal) + } else { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + whoButton.setTitle(anyone, for: .normal) + } + return header + } else { + let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header.update(title: sectionData.title) + return header + } + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() + } + + // remove the gap of table's footer + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == 2 || indexPath.section == 3 else { return } + + if indexPath.section == 2 { + coordinator.present( + scene: .webview(url: URL(string: "https://mastodon.online/terms")!), + from: self, + transition: .modal(animated: true, completion: nil)) + } + + // iTODO: clear media cache + + + // logout + if indexPath.section == 3, indexPath.row == 2 { + signout() + } + } +} + +// Update setting into core data +extension SettingsViewController { + func updateTrigger(by who: String) { + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(triggerBy: who) + } + .sink { (_) in + }.store(in: &disposeBag) + } + + func updateAlert(title: String?, isOn: Bool) { + guard let title = title else { return } + guard let settings = self.viewModel.setting.value else { return } + guard let triggerBy = settings.triggerBy else { return } + + var values: [Bool?]? + if let alerts = settings.subscription?.first(where: { (s) -> Bool in + return s.type == settings.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite) + items.append(alerts.follow) + items.append(alerts.reblog) + items.append(alerts.mention) + values = items + } + guard var alertValues = values else { return } + guard alertValues.count >= 4 else { return } + + switch title { + case L10n.Scene.Settings.Section.Notifications.favorites: + alertValues[0] = isOn + case L10n.Scene.Settings.Section.Notifications.follows: + alertValues[1] = isOn + case L10n.Scene.Settings.Section.Notifications.boosts: + alertValues[2] = isOn + case L10n.Scene.Settings.Section.Notifications.mentions: + alertValues[3] = isOn + default: break + } + self.viewModel.alertUpdate.send((triggerBy: triggerBy, values: alertValues)) + } +} + +extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { + print("[SettingsViewController]: didSelect \(didSelect)") + guard let setting = self.viewModel.setting.value else { return } + + context.managedObjectContext.performChanges { + setting.update(appearance: didSelect.rawValue) + } + .sink { (_) in + // change light / dark mode + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + }.store(in: &disposeBag) + } +} + +extension SettingsViewController: SettingsToggleCellDelegate { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { + updateAlert(title: cell.data?.title, isOn: didChangeStatus) + } +} + +extension SettingsViewController: ActiveLabelDelegate { + func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + coordinator.present( + scene: .webview(url: URL(string: "https://github.com/tootsuite/mastodon")!), + from: self, + transition: .modal(animated: true, completion: nil)) + } +} + +extension SettingsViewController { + static func updateOverrideUserInterfaceStyle(window: UIWindow?) { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + + guard let setting: Setting? = { + let domain = box.domain + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try AppContext.shared.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() else { return } + + guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { + return + } + + var overrideUserInterfaceStyle: UIUserInterfaceStyle! + switch didSelect { + case .automatic: + overrideUserInterfaceStyle = .unspecified + case .light: + overrideUserInterfaceStyle = .light + case .dark: + overrideUserInterfaceStyle = .dark + } + window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct SettingsViewController_Previews: PreviewProvider { + + static var previews: some View { + Group { + UIViewControllerPreview { () -> UIViewController in + return SettingsViewController() + } + .previewLayout(.fixed(width: 390, height: 844)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift new file mode 100644 index 000000000..1f5bf4e4f --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -0,0 +1,295 @@ +// +// SettingsViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class SettingsViewModel: NSObject, NeedsDependency { + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + + var dataSource: UITableViewDiffableDataSource! + var disposeBag = Set() + let viewDidLoad = PassthroughSubject() + lazy var fetchResultsController: NSFetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + if let box = + self.context.authenticationService.activeMastodonAuthenticationBox.value { + let domain = box.domain + fetchRequest.predicate = Setting.predicate(domain: domain) + } + + fetchRequest.fetchLimit = 1 + fetchRequest.returnsObjectsAsFaults = false + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + controller.delegate = self + return controller + }() + let setting = CurrentValueSubject(nil) + + /// trigger when + /// - init alerts + /// - change subscription status everytime + let alertUpdate = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + lazy var notificationDefaultValue: [String: [Bool?]] = { + let followerSwitchItems: [Bool?] = [true, nil, true, true] + let anyoneSwitchItems: [Bool?] = [true, true, true, true] + let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] + let followSwitchItems: [Bool?] = [true, true, true, true] + + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower + let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + return [anyone: anyoneSwitchItems, + follower: followerSwitchItems, + follow: followSwitchItems, + noOne: noOneSwitchItems] + }() + + struct Input { + } + + struct Output { + } + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + self.coordinator = coordinator + + super.init() + } + + func transform(input: Input?) -> Output? { + //guard let input = input else { return nil } + + // build data for table view + buildDataSource() + + // request subsription data for updating or initialization + requestSubscription() + + typealias SubscriptionResponse = Mastodon.Response.Content + alertUpdate + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .flatMap { [weak self] (arg) -> AnyPublisher in + let (triggerBy, values) = arg + guard let self = self else { + return Empty().eraseToAnyPublisher() + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return Empty().eraseToAnyPublisher() + } + guard values.count >= 4 else { + return Empty().eraseToAnyPublisher() + } + + typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + return self.context.apiService.changeSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: Query(favourite: values[0], follow: values[1], reblog: values[2], mention: values[3], poll: nil), + triggerBy: triggerBy) + } + .sink { _ in + } receiveValue: { (subscription) in + } + .store(in: &disposeBag) + + + do { + try fetchResultsController.performFetch() + setting.value = fetchResultsController.fetchedObjects?.first + } catch { + assertionFailure(error.localizedDescription) + } + return nil + } + + // MARK: - Private methods + fileprivate func processDataSource(_ settings: Setting?) { + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic + let appearanceItem = SettingsItem.apperance(item: appearnceMode) + let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) + snapshot.appendSections([appearance]) + snapshot.appendItems([appearanceItem]) + + // notifications + var switches: [Bool?]? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite) + items.append(alerts.follow) + items.append(alerts.reblog) + items.append(alerts.mention) + switches = items + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + self.alertUpdate.send((triggerBy: triggerBy, values: values)) + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + } + let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, + L10n.Scene.Settings.Section.Notifications.follows, + L10n.Scene.Settings.Section.Notifications.boosts, + L10n.Scene.Settings.Section.Notifications.mentions,] + var notificationItems = [SettingsItem]() + for (i, noti) in notifications.enumerated() { + var value: Bool? = nil + if let switches = switches, i < switches.count { + value = switches[i] + } + + let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) + notificationItems.append(item) + } + let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) + snapshot.appendSections([notificationSection]) + snapshot.appendItems(notificationItems) + + // boring zone + let boringLinks = [L10n.Scene.Settings.Section.BoringZone.terms, + L10n.Scene.Settings.Section.BoringZone.privacy] + var boringLinkItems = [SettingsItem]() + for l in boringLinks { + // FIXME: update color in both light and dark mode + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) + boringLinkItems.append(item) + } + let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.BoringZone.title, items: boringLinkItems) + snapshot.appendSections([boringSection]) + snapshot.appendItems(boringLinkItems) + + // spicy zone + let spicyLinks = [L10n.Scene.Settings.Section.SpicyZone.clear, + L10n.Scene.Settings.Section.SpicyZone.signOut] + var spicyLinkItems = [SettingsItem]() + for l in spicyLinks { + // FIXME: update color in both light and dark mode + let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed)) + spicyLinkItems.append(item) + } + let spicySection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.SpicyZone.title, items: spicyLinkItems) + snapshot.appendSections([spicySection]) + snapshot.appendItems(spicyLinkItems) + + self.dataSource.apply(snapshot, animatingDifferences: false) + } + + private func buildDataSource() { + setting.filter({ $0 != nil }).sink { [weak self] (settings) in + guard let self = self else { return } + self.processDataSource(settings) + } + .store(in: &disposeBag) + + // init with no subscription for notification + let settings: Setting? = nil + self.processDataSource(settings) + } + + private func requestSubscription() { + // request subscription of notifications + typealias SubscriptionResponse = Mastodon.Response.Content + viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher in + guard let self = self, + let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return Empty().eraseToAnyPublisher() + } + + let domain = activeMastodonAuthenticationBox.domain + return self.context.apiService.subscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox) + } + .sink { _ in + } receiveValue: { (subscription) in + } + .store(in: &disposeBag) + } +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingsViewModel: NSFetchedResultsControllerDelegate { + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard controller === fetchResultsController else { + return + } + + setting.value = fetchResultsController.fetchedObjects?.first + } + +} + +enum SettingsSection: Hashable { + case apperance(title: String, selectedMode: SettingsItem) + case notifications(title: String, items: [SettingsItem]) + case boringZone(title: String, items: [SettingsItem]) + case spicyZone(tilte: String, items: [SettingsItem]) + + var title: String { + switch self { + case .apperance(let title, _), + .notifications(let title, _), + .boringZone(let title, _), + .spicyZone(let title, _): + return title + } + } +} + +enum SettingsItem: Hashable { + enum AppearanceMode: String { + case automatic + case light + case dark + } + + struct NotificationSwitch: Hashable { + let title: String + let isOn: Bool + let enable: Bool + } + + struct Link: Hashable { + let title: String + let color: UIColor + } + + case apperance(item: AppearanceMode) + case notification(item: NotificationSwitch) + case boringZone(item: Link) + case spicyZone(item: Link) +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift new file mode 100644 index 000000000..83932a62e --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -0,0 +1,207 @@ +// +// SettingsAppearanceTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsAppearanceTableViewCellDelegate: class { + func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) +} + +class AppearanceView: UIView { + lazy var imageView: UIImageView = { + let view = UIImageView() + return view + }() + lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = Asset.Colors.Label.primary.color + label.textAlignment = .center + return label + }() + lazy var checkBox: UIButton = { + let button = UIButton() + button.isUserInteractionEnabled = false + button.setImage(UIImage(systemName: "circle"), for: .normal) + button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) + button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + button.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + button.imageView?.contentMode = .scaleAspectFill + return button + }() + lazy var stackView: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.spacing = 10 + view.distribution = .equalSpacing + return view + }() + + var selected: Bool = false { + didSet { + checkBox.isSelected = selected + if selected { + checkBox.imageView?.tintColor = Asset.Colors.lightBrandBlue.color + } else { + checkBox.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + } + } + } + + // MARK: - Methods + init(image: UIImage?, title: String) { + super.init(frame: .zero) + setupUI() + + imageView.image = image + titleLabel.text = title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Private methods + private func setupUI() { + stackView.addArrangedSubview(imageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(checkBox) + + addSubview(stackView) + translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 218.0 / 100.0), + ]) + } +} + +class SettingsAppearanceTableViewCell: UITableViewCell { + weak var delegate: SettingsAppearanceTableViewCellDelegate? + var appearance: SettingsItem.AppearanceMode = .automatic { + didSet { + guard let delegate = self.delegate else { return } + delegate.settingsAppearanceCell(self, didSelect: appearance) + } + } + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + view.axis = .horizontal + view.distribution = .fillEqually + view.spacing = 18 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let automatic = AppearanceView(image: Asset.Settings.appearanceAutomatic.image, + title: L10n.Scene.Settings.Section.Appearance.automatic) + let light = AppearanceView(image: Asset.Settings.appearanceLight.image, + title: L10n.Scene.Settings.Section.Appearance.light) + let dark = AppearanceView(image: Asset.Settings.appearanceDark.image, + title: L10n.Scene.Settings.Section.Appearance.dark) + + lazy var automaticTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var lightTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + lazy var darkTap: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) + return tapGestureRecognizer + }() + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // remove seperator line in section of group tableview + for subview in self.subviews { + if subview != self.contentView && subview.frame.width == self.frame.width { + subview.removeFromSuperview() + } + } + } + + func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + appearance = data + self.delegate = delegate + + automatic.selected = false + light.selected = false + dark.selected = false + + switch data { + case .automatic: + automatic.selected = true + case .light: + light.selected = true + case .dark: + dark.selected = true + } + } + + // MARK: Private methods + private func setupUI() { + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + selectionStyle = .none + contentView.addSubview(stackView) + + stackView.addArrangedSubview(automatic) + stackView.addArrangedSubview(light) + stackView.addArrangedSubview(dark) + + automatic.addGestureRecognizer(automaticTap) + light.addGestureRecognizer(lightTap) + dark.addGestureRecognizer(darkTap) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } + + // MARK: - Actions + @objc func appearanceDidTap(sender: UIGestureRecognizer) { + if sender == automaticTap { + appearance = .automatic + } + + if sender == lightTap { + appearance = .light + } + + if sender == darkTap { + appearance = .dark + } + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift new file mode 100644 index 000000000..b5d0306d4 --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -0,0 +1,31 @@ +// +// SettingsLinkTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + selectionStyle = .none + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + textLabel?.alpha = highlighted ? 0.6 : 1.0 + } + + // MARK: - Methods + func update(with data: SettingsItem.Link) { + textLabel?.text = data.title + textLabel?.textColor = data.color + } +} diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift new file mode 100644 index 000000000..374e05d6d --- /dev/null +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -0,0 +1,70 @@ +// +// SettingsToggleTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +protocol SettingsToggleCellDelegate: class { + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) +} + +class SettingsToggleTableViewCell: UITableViewCell { + lazy var switchButton: UISwitch = { + let view = UISwitch(frame:.zero) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var data: SettingsItem.NotificationSwitch? + weak var delegate: SettingsToggleCellDelegate? + + // MARK: - Methods + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + setupUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { + self.delegate = delegate + self.data = data + textLabel?.text = data.title + switchButton.isOn = data.isOn + setup(enable: data.enable) + } + + // MARK: Actions + @objc func valueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + } + + // MARK: Private methods + private func setupUI() { + selectionStyle = .none + textLabel?.font = .systemFont(ofSize: 17, weight: .regular) + contentView.addSubview(switchButton) + + NSLayoutConstraint.activate([ + switchButton.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + switchButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + } + + private func setup(enable: Bool) { + if enable { + textLabel?.textColor = Asset.Colors.Label.primary.color + } else { + textLabel?.textColor = Asset.Colors.Label.secondary.color + } + switchButton.isEnabled = enable + } +} diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift new file mode 100644 index 000000000..67dff9822 --- /dev/null +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -0,0 +1,54 @@ +// +// SettingsSectionHeader.swift +// Mastodon +// +// Created by ihugo on 2021/4/8. +// + +import UIKit + +/// section header which supports add a custom view blelow the title +class SettingsSectionHeader: UIView { + lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + + lazy var stackView: UIStackView = { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: 40, left: 12, bottom: 10, right: 12) + view.axis = .vertical + return view + }() + + init(frame: CGRect, customView: UIView? = nil) { + super.init(frame: frame) + + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + stackView.addArrangedSubview(titleLabel) + if let view = customView { + stackView.addArrangedSubview(view) + } + + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + stackView.topAnchor.constraint(equalTo: self.topAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(title: String?) { + titleLabel.text = title?.uppercased() + } +} diff --git a/Mastodon/Service/APIService/APIService+Notifications.swift b/Mastodon/Service/APIService/APIService+Notifications.swift new file mode 100644 index 000000000..9bbcd180f --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Notifications.swift @@ -0,0 +1,66 @@ +// +// APIService+Settings.swift +// Mastodon +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func subscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + + return Mastodon.API.Notification.subscription( + session: session, + domain: domain, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } + + func changeSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Notification.CreateSubscriptionQuery, + triggerBy: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notification.createSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } +} + diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift new file mode 100644 index 000000000..b3cd004b0 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift @@ -0,0 +1,129 @@ +// +// APIService+CoreData+Notification.swift +// Mastodon +// +// Created by ihugo on 2021/4/11. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + domain: String, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property) + return (setting, true) + } + } + + static func createOrMergeSubscription( + into managedObjectContext: NSManagedObjectContext, + entity: Mastodon.Entity.Subscription, + domain: String, + triggerBy: String? = nil + ) -> (Subscription: Subscription, isCreated: Bool) { + // create setting entity if possible + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + var setting: Setting! + if let oldSetting = oldSetting { + setting = oldSetting + } else { + let property = Setting.Property( + appearance: "automatic", + triggerBy: "anyone", + domain: domain) + (setting, _) = createOrMergeSetting( + into: managedObjectContext, + domain: domain, + property: property) + } + + let oldSubscription: Subscription? = { + let request = Subscription.sortedFetchRequest + request.predicate = Subscription.predicate(id: entity.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + let property = Subscription.Property( + endpoint: entity.endpoint, + id: entity.id, + serverKey: entity.serverKey, + type: triggerBy ?? setting.triggerBy ?? "") + let alertEntity = entity.alerts + let alert = SubscriptionAlerts.Property( + favourite: alertEntity.favourite, + follow: alertEntity.follow, + mention: alertEntity.mention, + poll: alertEntity.poll, + reblog: alertEntity.reblog) + if let oldSubscription = oldSubscription { + oldSubscription.updateIfNeed(property: property) + if nil == oldSubscription.alert { + oldSubscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + } else { + oldSubscription.alert?.updateIfNeed(property: alert) + } + + if oldSubscription.alert?.hasChanges == true { + // don't expand subscription if add existed subscription + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + } + return (oldSubscription, false) + } else { + let subscription = Subscription.insert( + into: managedObjectContext, + property: property + ) + subscription.alert = SubscriptionAlerts.insert( + into: managedObjectContext, + property: alert) + setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + return (subscription, true) + } + } +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index e13395ccd..0f5e2bd59 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -27,6 +27,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setup() sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() + + // update `overrideUserInterfaceStyle` with current setting + SettingsViewController.updateOverrideUserInterfaceStyle(window: window) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift new file mode 100644 index 000000000..555dd22d5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -0,0 +1,135 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation +import Combine + +extension Mastodon.API.Notification { + + static func pushEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription") + } + + /// Get current subscription + /// + /// Using this endpoint to get current subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func subscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Change types of notifications + /// + /// Using this endpoint to change types of notifications + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func createSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization?, + query: CreateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.Notification { + public struct CreateSubscriptionQuery: PostQuery { + var queryItems: [URLQueryItem]? + var contentType: String? + var body: Data? + + let follow: Bool? + let favourite: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + // iTODO: missing parameters + // subscription[endpoint] + // subscription[keys][p256dh] + // subscription[keys][auth] + public init(favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool?) { + self.follow = follow + self.favourite = favourite + self.reblog = reblog + self.mention = mention + self.poll = poll + + queryItems = [URLQueryItem]() + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + queryItems?.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + queryItems?.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + queryItems?.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + queryItems?.append(mentionItem) + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 2fdb9b346..d04071e9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -115,6 +115,7 @@ extension Mastodon.API { public enum Trends { } public enum Suggestions { } public enum Notifications { } + public enum Notification { } } extension Mastodon.API { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift new file mode 100644 index 000000000..24bb2c189 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -0,0 +1,42 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/9. +// + +import Foundation + + +extension Mastodon.Entity { + /// Subscription + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) + public struct Subscription: Codable { + // Base + public let id: String + public let endpoint: String + public let alerts: Alerts + public let serverKey: String + + enum CodingKeys: String, CodingKey { + case id + case endpoint + case serverKey = "server_key" + case alerts + } + + public struct Alerts: Codable { + public let follow: Bool + public let favourite: Bool + public let reblog: Bool + public let mention: Bool + public let poll: Bool + } + } +} diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..cc8600c7e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -25,4 +25,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a -COCOAPODS: 1.10.1 +COCOAPODS: 1.10.0 diff --git a/SubscriptionAlerts.swift b/SubscriptionAlerts.swift new file mode 100644 index 000000000..928a777f5 --- /dev/null +++ b/SubscriptionAlerts.swift @@ -0,0 +1,131 @@ +// +// PushSubscriptionAlerts+CoreDataClass.swift +// CoreDataStack +// +// Created by ihugo on 2021/4/9. +// +// + +import Foundation +import CoreData + +@objc(SubscriptionAlerts) +public final class SubscriptionAlerts: NSManagedObject { + @NSManaged public var follow: Bool + @NSManaged public var favourite: Bool + @NSManaged public var reblog: Bool + @NSManaged public var mention: Bool + @NSManaged public var poll: Bool + + @NSManaged public private(set) var createdAt: Date + @NSManaged public private(set) var updatedAt: Date + + // MARK: - relationships + @NSManaged public var pushSubscription: Subscription? +} + +public extension SubscriptionAlerts { + override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) + } + + func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + + @discardableResult + static func insert( + into context: NSManagedObjectContext, + property: Property + ) -> SubscriptionAlerts { + let alerts: SubscriptionAlerts = context.insertObject() + alerts.favourite = property.favourite + alerts.follow = property.follow + alerts.mention = property.mention + alerts.poll = property.poll + alerts.reblog = property.reblog + return alerts + } + + func update(favourite: Bool) { + guard self.favourite != favourite else { return } + self.favourite = favourite + + didUpdate(at: Date()) + } + + func update(follow: Bool) { + guard self.follow != follow else { return } + self.follow = follow + + didUpdate(at: Date()) + } + + func update(mention: Bool) { + guard self.mention != mention else { return } + self.mention = mention + + didUpdate(at: Date()) + } + + func update(poll: Bool) { + guard self.poll != poll else { return } + self.poll = poll + + didUpdate(at: Date()) + } + + func update(reblog: Bool) { + guard self.reblog != reblog else { return } + self.reblog = reblog + + didUpdate(at: Date()) + } +} + +public extension SubscriptionAlerts { + struct Property { + public let favourite: Bool + public let follow: Bool + public let mention: Bool + public let poll: Bool + public let reblog: Bool + + public init(favourite: Bool?, follow: Bool?, mention: Bool?, poll: Bool?, reblog: Bool?) { + self.favourite = favourite ?? true + self.follow = follow ?? true + self.mention = mention ?? true + self.poll = poll ?? true + self.reblog = reblog ?? true + } + } + + func updateIfNeed(property: Property) { + if self.follow != property.follow { + self.follow = property.follow + } + + if self.favourite != property.favourite { + self.favourite = property.favourite + } + + if self.reblog != property.reblog { + self.reblog = property.reblog + } + + if self.mention != property.mention { + self.mention = property.mention + } + + if self.poll != property.poll { + self.poll = property.poll + } + } +} + +extension SubscriptionAlerts: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \SubscriptionAlerts.createdAt, ascending: false)] + } +} From b2b8b707077cd2b90ad1408dec259a54c213d222 Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 12 Apr 2021 00:20:40 +0800 Subject: [PATCH 222/400] fix: signout does not work --- Mastodon/Scene/Settings/SettingsViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 95bfc8aa1..8da4faeaf 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -283,7 +283,7 @@ extension SettingsViewController: UITableViewDelegate { // logout - if indexPath.section == 3, indexPath.row == 2 { + if indexPath.section == 3, indexPath.row == 1 { signout() } } From 23a06f04ab6300280dd3b2c3cb068086a74b44d7 Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 12 Apr 2021 21:42:43 +0800 Subject: [PATCH 223/400] fixed: subscription API call --- CoreDataStack/Entity/Subscription.swift | 1 + .../Settings/SettingsViewController.swift | 39 +++-- .../Scene/Settings/SettingsViewModel.swift | 156 ++++++++++++------ .../SettingsAppearanceTableViewCell.swift | 10 +- .../APIService/APIService+Notifications.swift | 28 ++++ .../APIService+CoreData+Notification.swift | 16 +- .../MastodonSDK/API/Mastodon+API+Push.swift | 107 +++++++++--- .../Entity/Mastodon+Entity+Subscription.swift | 45 ++++- SubscriptionAlerts.swift | 42 ++--- 9 files changed, 320 insertions(+), 124 deletions(-) diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 5d65129e2..7d7a74570 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -50,6 +50,7 @@ public extension Subscription { setting.id = property.id setting.endpoint = property.endpoint setting.serverKey = property.serverKey + setting.type = property.type return setting } diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 8da4faeaf..73dc43df4 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -12,6 +12,8 @@ import ActiveLabel import CoreData import CoreDataStack +// iTODO: when to ask permission to Use Notifications + class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -26,7 +28,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne let menu = UIMenu( - image: UIImage(systemName: "escape"), + image: nil, identifier: nil, options: .displayInline, children: [ @@ -173,7 +175,9 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupTableView() { - viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in + viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in + guard let self = self else { return nil } + switch item { case .apperance(let item): guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { @@ -227,6 +231,10 @@ class SettingsViewController: UIViewController, NeedsDependency { .store(in: &disposeBag) } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + // Mark: - Actions @objc func doneButtonDidClick() { dismiss(animated: true, completion: nil) @@ -306,38 +314,39 @@ extension SettingsViewController { guard let settings = self.viewModel.setting.value else { return } guard let triggerBy = settings.triggerBy else { return } - var values: [Bool?]? - if let alerts = settings.subscription?.first(where: { (s) -> Bool in + guard let alerts = settings.subscription?.first(where: { (s) -> Bool in return s.type == settings.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite) - items.append(alerts.follow) - items.append(alerts.reblog) - items.append(alerts.mention) - values = items + })?.alert else { + return } - guard var alertValues = values else { return } - guard alertValues.count >= 4 else { return } + var alertValues = [Bool?]() + alertValues.append(alerts.favourite?.boolValue) + alertValues.append(alerts.follow?.boolValue) + alertValues.append(alerts.reblog?.boolValue) + alertValues.append(alerts.mention?.boolValue) + // need to update `alerts` to make update API with correct parameter switch title { case L10n.Scene.Settings.Section.Notifications.favorites: alertValues[0] = isOn + alerts.favourite = NSNumber(booleanLiteral: isOn) case L10n.Scene.Settings.Section.Notifications.follows: alertValues[1] = isOn + alerts.follow = NSNumber(booleanLiteral: isOn) case L10n.Scene.Settings.Section.Notifications.boosts: alertValues[2] = isOn + alerts.reblog = NSNumber(booleanLiteral: isOn) case L10n.Scene.Settings.Section.Notifications.mentions: alertValues[3] = isOn + alerts.mention = NSNumber(booleanLiteral: isOn) default: break } - self.viewModel.alertUpdate.send((triggerBy: triggerBy, values: alertValues)) + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) } } extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { - print("[SettingsViewController]: didSelect \(didSelect)") guard let setting = self.viewModel.setting.value else { return } context.managedObjectContext.performChanges { diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 1f5bf4e4f..6a817ffff 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -20,6 +20,9 @@ class SettingsViewModel: NSObject, NeedsDependency { var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() + var updateDisposeBag = Set() + var createDisposeBag = Set() + let viewDidLoad = PassthroughSubject() lazy var fetchResultsController: NSFetchedResultsController = { let fetchRequest = Setting.sortedFetchRequest @@ -42,10 +45,14 @@ class SettingsViewModel: NSObject, NeedsDependency { }() let setting = CurrentValueSubject(nil) - /// trigger when - /// - init alerts - /// - change subscription status everytime - let alertUpdate = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + /// create a subscription when: + /// - does not has one + /// - does not find subscription for selected trigger when change trigger + let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + /// update a subscription when: + /// - change switch for specified alerts + let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() lazy var notificationDefaultValue: [String: [Bool?]] = { let followerSwitchItems: [Bool?] = [true, nil, true, true] @@ -77,7 +84,85 @@ class SettingsViewModel: NSObject, NeedsDependency { } func transform(input: Input?) -> Output? { - //guard let input = input else { return nil } + typealias SubscriptionResponse = Mastodon.Response.Content + createSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.createDisposeBag.removeAll() + typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + endpoint: "http://www.google.com", + p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", + auth: "4vQK-SvRAN5eo-8ASlrwA==", + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil) + self.context.apiService.changeSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.createDisposeBag) + } + .store(in: &disposeBag) + + updateSubscriptionSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { _ in + } receiveValue: { [weak self] (arg) in + let (triggerBy, values) = arg + guard let self = self else { + return + } + guard let activeMastodonAuthenticationBox = + self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + guard values.count >= 4 else { + return + } + + self.updateDisposeBag.removeAll() + typealias Query = Mastodon.API.Notification.UpdateSubscriptionQuery + let domain = activeMastodonAuthenticationBox.domain + let query = Query( + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil) + self.context.apiService.updateSubscription( + domain: domain, + mastodonAuthenticationBox: activeMastodonAuthenticationBox, + query: query, + triggerBy: triggerBy + ) + .sink { (_) in + } receiveValue: { (_) in + } + .store(in: &self.updateDisposeBag) + } + .store(in: &disposeBag) // build data for table view buildDataSource() @@ -85,36 +170,6 @@ class SettingsViewModel: NSObject, NeedsDependency { // request subsription data for updating or initialization requestSubscription() - typealias SubscriptionResponse = Mastodon.Response.Content - alertUpdate - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .flatMap { [weak self] (arg) -> AnyPublisher in - let (triggerBy, values) = arg - guard let self = self else { - return Empty().eraseToAnyPublisher() - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return Empty().eraseToAnyPublisher() - } - guard values.count >= 4 else { - return Empty().eraseToAnyPublisher() - } - - typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - return self.context.apiService.changeSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: Query(favourite: values[0], follow: values[1], reblog: values[2], mention: values[3], poll: nil), - triggerBy: triggerBy) - } - .sink { _ in - } receiveValue: { (subscription) in - } - .store(in: &disposeBag) - - do { try fetchResultsController.performFetch() setting.value = fetchResultsController.fetchedObjects?.first @@ -141,15 +196,15 @@ class SettingsViewModel: NSObject, NeedsDependency { return s.type == settings?.triggerBy })?.alert { var items = [Bool?]() - items.append(alerts.favourite) - items.append(alerts.follow) - items.append(alerts.reblog) - items.append(alerts.mention) + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) switches = items } else if let triggerBy = settings?.triggerBy, let values = self.notificationDefaultValue[triggerBy] { switches = values - self.alertUpdate.send((triggerBy: triggerBy, values: values)) + self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) } else { // fallback a default value let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone @@ -178,7 +233,6 @@ class SettingsViewModel: NSObject, NeedsDependency { L10n.Scene.Settings.Section.BoringZone.privacy] var boringLinkItems = [SettingsItem]() for l in boringLinks { - // FIXME: update color in both light and dark mode let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) boringLinkItems.append(item) } @@ -191,7 +245,6 @@ class SettingsViewModel: NSObject, NeedsDependency { L10n.Scene.Settings.Section.SpicyZone.signOut] var spicyLinkItems = [SettingsItem]() for l in spicyLinks { - // FIXME: update color in both light and dark mode let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed)) spicyLinkItems.append(item) } @@ -203,15 +256,11 @@ class SettingsViewModel: NSObject, NeedsDependency { } private func buildDataSource() { - setting.filter({ $0 != nil }).sink { [weak self] (settings) in + setting.sink { [weak self] (settings) in guard let self = self else { return } self.processDataSource(settings) } .store(in: &disposeBag) - - // init with no subscription for notification - let settings: Setting? = nil - self.processDataSource(settings) } private func requestSubscription() { @@ -229,11 +278,22 @@ class SettingsViewModel: NSObject, NeedsDependency { domain: domain, mastodonAuthenticationBox: activeMastodonAuthenticationBox) } - .sink { _ in + .sink { [weak self] competion in + if case .failure(_) = competion { + // create a subscription when doesn't has one + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + if let values = self?.notificationDefaultValue[anyone] { + self?.createSubscriptionSubject.send((triggerBy: anyone, values: values)) + } + } } receiveValue: { (subscription) in } .store(in: &disposeBag) } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } } // MARK: - NSFetchedResultsControllerDelegate diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index 83932a62e..7419e2cad 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -86,12 +86,7 @@ class AppearanceView: UIView { class SettingsAppearanceTableViewCell: UITableViewCell { weak var delegate: SettingsAppearanceTableViewCellDelegate? - var appearance: SettingsItem.AppearanceMode = .automatic { - didSet { - guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelect: appearance) - } - } + var appearance: SettingsItem.AppearanceMode = .automatic lazy var stackView: UIStackView = { let view = UIStackView() @@ -203,5 +198,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell { if sender == darkTap { appearance = .dark } + + guard let delegate = self.delegate else { return } + delegate.settingsAppearanceCell(self, didSelect: appearance) } } diff --git a/Mastodon/Service/APIService/APIService+Notifications.swift b/Mastodon/Service/APIService/APIService+Notifications.swift index 9bbcd180f..b32251544 100644 --- a/Mastodon/Service/APIService/APIService+Notifications.swift +++ b/Mastodon/Service/APIService/APIService+Notifications.swift @@ -62,5 +62,33 @@ extension APIService { .eraseToAnyPublisher() }.eraseToAnyPublisher() } + + func updateSubscription( + domain: String, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + query: Mastodon.API.Notification.UpdateSubscriptionQuery, + triggerBy: String + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notification.updateSubscription( + session: session, + domain: domain, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() + } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift index b3cd004b0..8dc189734 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift @@ -94,11 +94,12 @@ extension APIService.CoreData { type: triggerBy ?? setting.triggerBy ?? "") let alertEntity = entity.alerts let alert = SubscriptionAlerts.Property( - favourite: alertEntity.favourite, - follow: alertEntity.follow, - mention: alertEntity.mention, - poll: alertEntity.poll, - reblog: alertEntity.reblog) + favourite: alertEntity.favouriteNumber, + follow: alertEntity.followNumber, + mention: alertEntity.mentionNumber, + poll: alertEntity.pollNumber, + reblog: alertEntity.reblogNumber + ) if let oldSubscription = oldSubscription { oldSubscription.updateIfNeed(property: property) if nil == oldSubscription.alert { @@ -109,9 +110,10 @@ extension APIService.CoreData { oldSubscription.alert?.updateIfNeed(property: alert) } - if oldSubscription.alert?.hasChanges == true { + if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { // don't expand subscription if add existed subscription - setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) + oldSubscription.didUpdate(at: Date()) } return (oldSubscription, false) } else { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index 555dd22d5..3618b06c5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -47,9 +47,9 @@ extension Mastodon.API.Notification { .eraseToAnyPublisher() } - /// Change types of notifications + /// Subscribe to push notifications /// - /// Using this endpoint to change types of notifications + /// Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted. /// /// - Since: 2.4.0 /// - Version: 3.3.0 @@ -80,6 +80,40 @@ extension Mastodon.API.Notification { } .eraseToAnyPublisher() } + + /// Change types of notifications + /// + /// Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/9 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Poll` nested in the response + public static func updateSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization?, + query: UpdateSubscriptionQuery + ) -> AnyPublisher, Error> { + let request = Mastodon.API.put( + url: pushEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Subscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.Notification { @@ -88,27 +122,56 @@ extension Mastodon.API.Notification { var contentType: String? var body: Data? - let follow: Bool? - let favourite: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - - // iTODO: missing parameters - // subscription[endpoint] - // subscription[keys][p256dh] - // subscription[keys][auth] - public init(favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool?) { - self.follow = follow - self.favourite = favourite - self.reblog = reblog - self.mention = mention - self.poll = poll + public init( + endpoint: String, + p256dh: String, + auth: String, + favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool? + ) { + queryItems = [URLQueryItem]() + queryItems?.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) + queryItems?.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) + queryItems?.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + queryItems?.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + queryItems?.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + queryItems?.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + queryItems?.append(mentionItem) + } + } + } + + public struct UpdateSubscriptionQuery: PutQuery { + var queryItems: [URLQueryItem]? + var contentType: String? + var body: Data? + + public init( + favourite: Bool?, + follow: Bool?, + reblog: Bool?, + mention: Bool?, + poll: Bool? + ) { queryItems = [URLQueryItem]() if let followValue = follow?.queryItemValue { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift index 24bb2c189..3ae5718e6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -32,11 +32,46 @@ extension Mastodon.Entity { } public struct Alerts: Codable { - public let follow: Bool - public let favourite: Bool - public let reblog: Bool - public let mention: Bool - public let poll: Bool + public let follow: Bool? + public let favourite: Bool? + public let reblog: Bool? + public let mention: Bool? + public let poll: Bool? + + public var followNumber: NSNumber? { + guard let value = follow else { return nil } + return NSNumber(booleanLiteral: value) + } + public var favouriteNumber: NSNumber? { + guard let value = favourite else { return nil } + return NSNumber(booleanLiteral: value) + } + public var reblogNumber: NSNumber? { + guard let value = reblog else { return nil } + return NSNumber(booleanLiteral: value) + } + public var mentionNumber: NSNumber? { + guard let value = mention else { return nil } + return NSNumber(booleanLiteral: value) + } + public var pollNumber: NSNumber? { + guard let value = poll else { return nil } + return NSNumber(booleanLiteral: value) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + var id = try? container.decode(String.self, forKey: .id) + if nil == id, let numId = try? container.decode(Int.self, forKey: .id) { + id = String(numId) + } + self.id = id ?? "" + + endpoint = try container.decode(String.self, forKey: .endpoint) + alerts = try container.decode(Alerts.self, forKey: .alerts) + serverKey = try container.decode(String.self, forKey: .serverKey) } } } diff --git a/SubscriptionAlerts.swift b/SubscriptionAlerts.swift index 928a777f5..c240a02a5 100644 --- a/SubscriptionAlerts.swift +++ b/SubscriptionAlerts.swift @@ -11,11 +11,11 @@ import CoreData @objc(SubscriptionAlerts) public final class SubscriptionAlerts: NSManagedObject { - @NSManaged public var follow: Bool - @NSManaged public var favourite: Bool - @NSManaged public var reblog: Bool - @NSManaged public var mention: Bool - @NSManaged public var poll: Bool + @NSManaged public var follow: NSNumber? + @NSManaged public var favourite: NSNumber? + @NSManaged public var reblog: NSNumber? + @NSManaged public var mention: NSNumber? + @NSManaged public var poll: NSNumber? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -48,35 +48,35 @@ public extension SubscriptionAlerts { return alerts } - func update(favourite: Bool) { + func update(favourite: NSNumber?) { guard self.favourite != favourite else { return } self.favourite = favourite didUpdate(at: Date()) } - func update(follow: Bool) { + func update(follow: NSNumber?) { guard self.follow != follow else { return } self.follow = follow didUpdate(at: Date()) } - func update(mention: Bool) { + func update(mention: NSNumber?) { guard self.mention != mention else { return } self.mention = mention didUpdate(at: Date()) } - func update(poll: Bool) { + func update(poll: NSNumber?) { guard self.poll != poll else { return } self.poll = poll didUpdate(at: Date()) } - func update(reblog: Bool) { + func update(reblog: NSNumber?) { guard self.reblog != reblog else { return } self.reblog = reblog @@ -86,18 +86,18 @@ public extension SubscriptionAlerts { public extension SubscriptionAlerts { struct Property { - public let favourite: Bool - public let follow: Bool - public let mention: Bool - public let poll: Bool - public let reblog: Bool + public let favourite: NSNumber? + public let follow: NSNumber? + public let mention: NSNumber? + public let poll: NSNumber? + public let reblog: NSNumber? - public init(favourite: Bool?, follow: Bool?, mention: Bool?, poll: Bool?, reblog: Bool?) { - self.favourite = favourite ?? true - self.follow = follow ?? true - self.mention = mention ?? true - self.poll = poll ?? true - self.reblog = reblog ?? true + public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { + self.favourite = favourite + self.follow = follow + self.mention = mention + self.poll = poll + self.reblog = reblog } } From 6f8b71f25f7cbeb8e01a43630b58403af51fc46d Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 12 Apr 2021 23:03:39 +0800 Subject: [PATCH 224/400] chore: remove .lock file --- Mastodon/Resources/Assets.xcassets/.DS_Store | Bin 6148 -> 0 bytes .../Resources/Assets.xcassets/Welcome/.DS_Store | Bin 6148 -> 0 bytes Podfile.lock | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/.DS_Store delete mode 100644 Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store diff --git a/Mastodon/Resources/Assets.xcassets/.DS_Store b/Mastodon/Resources/Assets.xcassets/.DS_Store deleted file mode 100644 index 4fb9bcc5505725330d7145831e719cf5a4e88381..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKK~BRk5Zr|xB5}#FN8dT+2cZft$Olj~S!c&;>E-0Zna6ky5 z-N>G`y}PzYitQBs5!=iJjzy{cH63*3G^4cxb{L? zxszvnqxIkR-F~#-6PvDYn)R+<)Bo^xTwa#1S97tte)BE6{e9JbnR7IKsKG!m5DWwZ z!N89(fIFK~To^_j39`Z_K_ diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store b/Mastodon/Resources/Assets.xcassets/Welcome/.DS_Store deleted file mode 100644 index 523b0748c86e7d8534ee845c945a235f40857856..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyG{c^3>-s>NHi%aDE14G_=8gvz92t9q@V~XT%;sWU&n7Vetev+yiSg;6i4lM}V>pC<%o4=r0b*A;Br-y=EG1^C)retP&UmZ5u5d`qa##%? zRySKsC>FQ#{1)l3E>Ttth=DN!*STDH|9_ Date: Tue, 13 Apr 2021 16:22:41 +0800 Subject: [PATCH 225/400] fix: fix some reveiw issues --- Localization/app.json | 40 ++++++ Mastodon/Coordinator/SceneCoordinator.swift | 5 + Mastodon/Generated/Strings.swift | 28 +++-- .../Resources/en.lproj/Localizable.strings | 33 ++--- .../Settings/SettingsViewController.swift | 37 ++++-- .../Scene/Settings/SettingsViewModel.swift | 14 +-- .../Cell/SettingsToggleTableViewCell.swift | 9 +- .../MastodonSDK/API/Mastodon+API+Push.swift | 114 +++++++++++------- 8 files changed, 190 insertions(+), 90 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 120458f74..facda97cf 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -22,6 +22,11 @@ "publish_post_failure": { "title": "Publish Failure", "message": "Failed to publish the post.\nPlease check your internet connection." + }, + "sign_out": { + "title": "Sign out", + "message": "Are you sure you want to sign out?", + "confirm": "Sign Out" } }, "controls": { @@ -321,6 +326,41 @@ }, "favorite": { "title": "Your Favorites" + }, + "settings": { + "title": "Settings", + "section": { + "appearance": { + "title": "Appearance", + "automatic": "Automatic", + "light": "Always Light", + "dark": "Always Dark" + }, + "notifications": { + "title": "Notifications", + "favorites": "Favorites my post", + "follows": "Follows me", + "boosts": "Reblogs my post", + "mentions": "Mentions me", + "trigger": { + "anyone": "anyone", + "follower": "a follower", + "follow": "anyone I follow", + "noone": "no one", + "title": "Notify me when" + } + }, + "boringzone": { + "title": "The Boring zone", + "terms": "Terms of Service", + "privacy": "Privacy Policy" + }, + "spicyzone": { + "title": "The spicy zone", + "clear": "Clear Media Cache", + "signout": "Sign Out" + } + } } } } diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 36d42745e..28064ac38 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -65,6 +65,7 @@ extension SceneCoordinator { #if DEBUG case publicTimeline + case settings #endif var isOnboarding: Bool { @@ -262,6 +263,10 @@ private extension SceneCoordinator { let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController + case .settings: + let _viewController = SettingsViewController() + _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + viewController = _viewController #endif } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index dac01a4b3..4b2175e86 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -35,6 +35,14 @@ internal enum L10n { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") } + internal enum SignOut { + /// Sign Out + internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm") + /// Are you sure you want to sign out? + internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message") + /// Sign out + internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title") + } internal enum SignUpFailure { /// Sign Up Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") @@ -595,16 +603,16 @@ internal enum L10n { /// Appearance internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") } - internal enum BoringZone { + internal enum Boringzone { /// Privacy Policy - internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Privacy") + internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy") /// Terms of Service - internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Terms") + internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms") /// The Boring zone - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.BoringZone.Title") + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title") } internal enum Notifications { - /// Boosts my post + /// Reblogs my post internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") /// Favorites my post internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") @@ -622,18 +630,18 @@ internal enum L10n { /// a follower internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") /// no one - internal static let noOne = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.NoOne") + internal static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone") /// Notify me when internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") } } - internal enum SpicyZone { + internal enum Spicyzone { /// Clear Media Cache - internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Clear") + internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear") /// Sign Out - internal static let signOut = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.SignOut") + internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout") /// The spicy zone - internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.SpicyZone.Title") + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title") } } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 6abcb4cfe..d584be424 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -6,6 +6,9 @@ Please check your internet connection."; "Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; +"Common.Alerts.SignOut.Title" = "Sign out"; "Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; "Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; "Common.Alerts.VoteFailure.Title" = "Vote Failure"; @@ -186,26 +189,26 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; -"Scene.Welcome.Slogan" = "Social networking -back in your hands."; -"Scene.Settings.Title" = "Settings"; -"Scene.Settings.Section.Appearance.Title" = "Appearance"; "Scene.Settings.Section.Appearance.Automatic" = "Automatic"; -"Scene.Settings.Section.Appearance.Light" = "Always Light"; "Scene.Settings.Section.Appearance.Dark" = "Always Dark"; -"Scene.Settings.Section.Notifications.Title" = "Notifications"; +"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Title" = "Appearance"; +"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; +"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; +"Scene.Settings.Section.Boringzone.Title" = "The Boring zone"; +"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; "Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; "Scene.Settings.Section.Notifications.Follows" = "Follows me"; -"Scene.Settings.Section.Notifications.Boosts" = "Boosts my post"; "Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.Notifications.Title" = "Notifications"; "Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; -"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; "Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; -"Scene.Settings.Section.Notifications.Trigger.NoOne" = "no one"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one"; "Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; -"Scene.Settings.Section.BoringZone.Title" = "The Boring zone"; -"Scene.Settings.Section.BoringZone.Terms" = "Terms of Service"; -"Scene.Settings.Section.BoringZone.Privacy" = "Privacy Policy"; -"Scene.Settings.Section.SpicyZone.Title" = "The spicy zone"; -"Scene.Settings.Section.SpicyZone.Clear" = "Clear Media Cache"; -"Scene.Settings.Section.SpicyZone.SignOut" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; +"Scene.Settings.Title" = "Settings"; +"Scene.Welcome.Slogan" = "Social networking +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 73dc43df4..c134ade33 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -26,7 +26,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow - let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone let menu = UIMenu( image: nil, identifier: nil, @@ -206,11 +206,32 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.tableFooterView = footerView } + func alertToSignout() { + let alertController = UIAlertController( + title: L10n.Common.Alerts.SignOut.title, + message: L10n.Common.Alerts.SignOut.message, + preferredStyle: .alert + ) + + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.signout() + } + alertController.addAction(cancelAction) + alertController.addAction(signOutAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: self, + transition: .alertController(animated: true, completion: nil) + ) + } + func signout() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - + context.authenticationService.signOutMastodonUser( domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID @@ -282,9 +303,10 @@ extension SettingsViewController: UITableViewDelegate { if indexPath.section == 2 { coordinator.present( - scene: .webview(url: URL(string: "https://mastodon.online/terms")!), + scene: .safari(url: URL(string: "https://mastodon.online/terms")!), from: self, - transition: .modal(animated: true, completion: nil)) + transition: .safariPresent(animated: true, completion: nil) + ) } // iTODO: clear media cache @@ -292,7 +314,7 @@ extension SettingsViewController: UITableViewDelegate { // logout if indexPath.section == 3, indexPath.row == 1 { - signout() + alertToSignout() } } } @@ -377,9 +399,10 @@ extension SettingsViewController: SettingsToggleCellDelegate { extension SettingsViewController: ActiveLabelDelegate { func activeLabel(_ activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { coordinator.present( - scene: .webview(url: URL(string: "https://github.com/tootsuite/mastodon")!), + scene: .safari(url: URL(string: "https://github.com/tootsuite/mastodon")!), from: self, - transition: .modal(animated: true, completion: nil)) + transition: .safariPresent(animated: true, completion: nil) + ) } } diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 6a817ffff..0d625c0ed 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -63,7 +63,7 @@ class SettingsViewModel: NSObject, NeedsDependency { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow - let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noOne + let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone return [anyone: anyoneSwitchItems, follower: followerSwitchItems, follow: followSwitchItems, @@ -229,26 +229,26 @@ class SettingsViewModel: NSObject, NeedsDependency { snapshot.appendItems(notificationItems) // boring zone - let boringLinks = [L10n.Scene.Settings.Section.BoringZone.terms, - L10n.Scene.Settings.Section.BoringZone.privacy] + let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, + L10n.Scene.Settings.Section.Boringzone.privacy] var boringLinkItems = [SettingsItem]() for l in boringLinks { let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) boringLinkItems.append(item) } - let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.BoringZone.title, items: boringLinkItems) + let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) snapshot.appendSections([boringSection]) snapshot.appendItems(boringLinkItems) // spicy zone - let spicyLinks = [L10n.Scene.Settings.Section.SpicyZone.clear, - L10n.Scene.Settings.Section.SpicyZone.signOut] + let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, + L10n.Scene.Settings.Section.Spicyzone.signout] var spicyLinkItems = [SettingsItem]() for l in spicyLinks { let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed)) spicyLinkItems.append(item) } - let spicySection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.SpicyZone.title, items: spicyLinkItems) + let spicySection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) snapshot.appendSections([spicySection]) snapshot.appendItems(spicyLinkItems) diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index 374e05d6d..b35b2b50f 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -14,7 +14,6 @@ protocol SettingsToggleCellDelegate: class { class SettingsToggleTableViewCell: UITableViewCell { lazy var switchButton: UISwitch = { let view = UISwitch(frame:.zero) - view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -48,13 +47,7 @@ class SettingsToggleTableViewCell: UITableViewCell { // MARK: Private methods private func setupUI() { selectionStyle = .none - textLabel?.font = .systemFont(ofSize: 17, weight: .regular) - contentView.addSubview(switchButton) - - NSLayoutConstraint.activate([ - switchButton.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), - switchButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - ]) + accessoryView = switchButton switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index 3618b06c5..ef383e4db 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -32,7 +32,7 @@ extension Mastodon.API.Notification { public static func subscription( session: URLSession, domain: String, - authorization: Mastodon.API.OAuth.Authorization? + authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: pushEndpointURL(domain: domain), @@ -65,7 +65,7 @@ extension Mastodon.API.Notification { public static func createSubscription( session: URLSession, domain: String, - authorization: Mastodon.API.OAuth.Authorization?, + authorization: Mastodon.API.OAuth.Authorization, query: CreateSubscriptionQuery ) -> AnyPublisher, Error> { let request = Mastodon.API.post( @@ -99,7 +99,7 @@ extension Mastodon.API.Notification { public static func updateSubscription( session: URLSession, domain: String, - authorization: Mastodon.API.OAuth.Authorization?, + authorization: Mastodon.API.OAuth.Authorization, query: UpdateSubscriptionQuery ) -> AnyPublisher, Error> { let request = Mastodon.API.put( @@ -117,10 +117,44 @@ extension Mastodon.API.Notification { } extension Mastodon.API.Notification { - public struct CreateSubscriptionQuery: PostQuery { - var queryItems: [URLQueryItem]? - var contentType: String? - var body: Data? + public struct CreateSubscriptionQuery: Codable, PostQuery { + let endpoint: String + let p256dh: String + let auth: String + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() + + items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) + items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) + items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) + + if let followValue = follow?.queryItemValue { + let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) + items.append(followItem) + } + + if let favouriteValue = favourite?.queryItemValue { + let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) + items.append(favouriteItem) + } + + if let reblogValue = reblog?.queryItemValue { + let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) + items.append(reblogItem) + } + + if let mentionValue = mention?.queryItemValue { + let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) + items.append(mentionItem) + } + return items + } public init( endpoint: String, @@ -132,38 +166,48 @@ extension Mastodon.API.Notification { mention: Bool?, poll: Bool? ) { - queryItems = [URLQueryItem]() - - queryItems?.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) - queryItems?.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) - queryItems?.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) + self.endpoint = endpoint + self.p256dh = p256dh + self.auth = auth + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + + public struct UpdateSubscriptionQuery: Codable, PutQuery { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + var queryItems: [URLQueryItem]? { + var items = [URLQueryItem]() if let followValue = follow?.queryItemValue { let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - queryItems?.append(followItem) + items.append(followItem) } if let favouriteValue = favourite?.queryItemValue { let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - queryItems?.append(favouriteItem) + items.append(favouriteItem) } if let reblogValue = reblog?.queryItemValue { let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - queryItems?.append(reblogItem) + items.append(reblogItem) } if let mentionValue = mention?.queryItemValue { let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - queryItems?.append(mentionItem) + items.append(mentionItem) } + return items } - } - - public struct UpdateSubscriptionQuery: PutQuery { - var queryItems: [URLQueryItem]? - var contentType: String? - var body: Data? public init( favourite: Bool?, @@ -172,27 +216,11 @@ extension Mastodon.API.Notification { mention: Bool?, poll: Bool? ) { - queryItems = [URLQueryItem]() - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - queryItems?.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - queryItems?.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - queryItems?.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - queryItems?.append(mentionItem) - } + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll } } } From 262eeeccba5d962e3a027065350462715f7ef926 Mon Sep 17 00:00:00 2001 From: ihugo Date: Tue, 13 Apr 2021 13:52:46 +0800 Subject: [PATCH 226/400] add: clear media cache --- .../Scene/Settings/SettingsViewController.swift | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index c134ade33..b43ca30e3 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -11,6 +11,8 @@ import Combine import ActiveLabel import CoreData import CoreDataStack +import AlamofireImage +import Kingfisher // iTODO: when to ask permission to Use Notifications @@ -309,8 +311,19 @@ extension SettingsViewController: UITableViewDelegate { ) } - // iTODO: clear media cache - + // clear media cache + if indexPath.section == 3, indexPath.row == 0 { + // clean image cache for AlamofireImage + let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) + ImageDownloader.defaultURLCache().removeAllCachedResponses() + let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) + + // clean Kingfisher Cache + KingfisherManager.shared.cache.clearDiskCache() + } // logout if indexPath.section == 3, indexPath.row == 1 { From 901176e14dfffeb1973a71bbd7e49cf6479ac28f Mon Sep 17 00:00:00 2001 From: ihugo Date: Tue, 13 Apr 2021 17:37:13 +0800 Subject: [PATCH 227/400] fix: fix compile error caused by git merge --- .../Entity/SubscriptionAlerts.swift | 0 Mastodon.xcodeproj/project.pbxproj | 68 +++++++++++++++++++ Mastodon/Generated/Assets.swift | 6 ++ .../Scene/Settings/SettingsViewModel.swift | 4 +- .../SettingsAppearanceTableViewCell.swift | 6 +- ...s.swift => APIService+Subscriptions.swift} | 10 +-- ...> APIService+CoreData+Subscriptions.swift} | 0 .../MastodonSDK/API/Mastodon+API+Push.swift | 4 +- .../MastodonSDK/API/Mastodon+API.swift | 2 +- 9 files changed, 87 insertions(+), 13 deletions(-) rename SubscriptionAlerts.swift => CoreDataStack/Entity/SubscriptionAlerts.swift (100%) rename Mastodon/Service/APIService/{APIService+Notifications.swift => APIService+Subscriptions.swift} (91%) rename Mastodon/Service/APIService/CoreData/{APIService+CoreData+Notification.swift => APIService+CoreData+Subscriptions.swift} (100%) diff --git a/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift similarity index 100% rename from SubscriptionAlerts.swift rename to CoreDataStack/Entity/SubscriptionAlerts.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b52139898..2911667bf 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -123,6 +123,17 @@ 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; }; + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; }; + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; + 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; @@ -503,6 +514,17 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; + 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; @@ -1136,6 +1158,35 @@ name = Frameworks; sourceTree = ""; }; + 5B90C455262599800002E742 /* Settings */ = { + isa = PBXGroup; + children = ( + 5B90C456262599800002E742 /* SettingsViewModel.swift */, + 5B90C457262599800002E742 /* View */, + 5B90C45D262599800002E742 /* SettingsViewController.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 5B90C457262599800002E742 /* View */ = { + isa = PBXGroup; + children = ( + 5B90C458262599800002E742 /* Cell */, + 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */, + ); + path = View; + sourceTree = ""; + }; + 5B90C458262599800002E742 /* Cell */ = { + isa = PBXGroup; + children = ( + 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */, + 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */, + 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */, + ); + path = Cell; + sourceTree = ""; + }; 5D03938E2612D200007FE196 /* Webview */ = { isa = PBXGroup; children = ( @@ -1320,6 +1371,7 @@ DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, + 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, ); path = APIService; sourceTree = ""; @@ -1331,6 +1383,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; sourceTree = ""; @@ -1500,6 +1553,9 @@ DB4481AC25EE155900BEFB67 /* Poll.swift */, DB4481B225EE16D000BEFB67 /* PollOption.swift */, DBCC3B9A2615849F0045B23D /* PrivateNote.swift */, + 5B90C46D26259B2C0002E742 /* Setting.swift */, + 5B90C46C26259B2C0002E742 /* Subscription.swift */, + 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */, ); path = Entity; sourceTree = ""; @@ -1551,6 +1607,7 @@ 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, + 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, @@ -2227,6 +2284,7 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */, 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, + 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, @@ -2241,6 +2299,7 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, + 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, @@ -2269,6 +2328,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, @@ -2276,6 +2336,7 @@ DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, + 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, @@ -2296,6 +2357,7 @@ DB7F48452620241000796008 /* ProfileHeaderViewModel.swift in Sources */, 2D3F9E0425DFA133004262D9 /* UITapGestureRecognizer.swift in Sources */, 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */, + 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, @@ -2305,6 +2367,7 @@ 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */, DB4563BD25E11A24004DA0B9 /* KeyboardResponderService.swift in Sources */, DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */, + 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */, DB44768B260B3F2100B66B82 /* CustomEmojiPickerItem.swift in Sources */, 2DF75BA125D0E29D00694EC8 /* StatusProvider+StatusTableViewCellDelegate.swift in Sources */, 5DF1056425F887CB00D6C0D4 /* AVPlayer.swift in Sources */, @@ -2377,7 +2440,9 @@ DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, + 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, + 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, @@ -2460,6 +2525,7 @@ 2D927F1425C7EDD9004F19B8 /* Emoji.swift in Sources */, 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */, DB89BA1225C1105C008580ED /* CoreDataStack.swift in Sources */, + 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */, DB89BA1C25C1107F008580ED /* NSManagedObjectContext.swift in Sources */, DB9D6C2E25E504AC0051B173 /* Attachment.swift in Sources */, 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */, @@ -2480,6 +2546,8 @@ DB89BA1D25C1107F008580ED /* URL.swift in Sources */, 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, + 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, + 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 843fce02c..5fd25edc4 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -81,6 +81,7 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } + internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let danger = ColorAsset(name: "Colors/danger") internal static let disabled = ColorAsset(name: "Colors/disabled") @@ -98,6 +99,11 @@ internal enum Asset { internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") } } + internal enum Settings { + internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic") + internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark") + internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light") + } internal enum Welcome { internal enum Illustration { internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 0d625c0ed..ddee6c5f9 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -102,7 +102,7 @@ class SettingsViewModel: NSObject, NeedsDependency { } self.createDisposeBag.removeAll() - typealias Query = Mastodon.API.Notification.CreateSubscriptionQuery + typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery let domain = activeMastodonAuthenticationBox.domain let query = Query( endpoint: "http://www.google.com", @@ -143,7 +143,7 @@ class SettingsViewModel: NSObject, NeedsDependency { } self.updateDisposeBag.removeAll() - typealias Query = Mastodon.API.Notification.UpdateSubscriptionQuery + typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery let domain = activeMastodonAuthenticationBox.domain let query = Query( favourite: values[0], diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index 7419e2cad..a477661ee 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -29,7 +29,7 @@ class AppearanceView: UIView { button.setImage(UIImage(systemName: "circle"), for: .normal) button.setImage(UIImage(systemName: "checkmark.circle.fill"), for: .selected) button.imageView?.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) - button.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + button.imageView?.tintColor = Asset.Colors.Label.secondary.color button.imageView?.contentMode = .scaleAspectFill return button }() @@ -45,9 +45,9 @@ class AppearanceView: UIView { didSet { checkBox.isSelected = selected if selected { - checkBox.imageView?.tintColor = Asset.Colors.lightBrandBlue.color + checkBox.imageView?.tintColor = Asset.Colors.Label.highlight.color } else { - checkBox.imageView?.tintColor = Asset.Colors.lightSecondaryText.color + checkBox.imageView?.tintColor = Asset.Colors.Label.secondary.color } } } diff --git a/Mastodon/Service/APIService/APIService+Notifications.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift similarity index 91% rename from Mastodon/Service/APIService/APIService+Notifications.swift rename to Mastodon/Service/APIService/APIService+Subscriptions.swift index b32251544..d67284c7c 100644 --- a/Mastodon/Service/APIService/APIService+Notifications.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -18,7 +18,7 @@ extension APIService { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Notification.subscription( + return Mastodon.API.Subscriptions.subscription( session: session, domain: domain, authorization: authorization) @@ -38,12 +38,12 @@ extension APIService { func changeSubscription( domain: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Notification.CreateSubscriptionQuery, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, triggerBy: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Notification.createSubscription( + return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, authorization: authorization, @@ -66,12 +66,12 @@ extension APIService { func updateSubscription( domain: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Notification.UpdateSubscriptionQuery, + query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, triggerBy: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Notification.updateSubscription( + return Mastodon.API.Subscriptions.updateSubscription( session: session, domain: domain, authorization: authorization, diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift similarity index 100% rename from Mastodon/Service/APIService/CoreData/APIService+CoreData+Notification.swift rename to Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index ef383e4db..062b45ac2 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -8,7 +8,7 @@ import Foundation import Combine -extension Mastodon.API.Notification { +extension Mastodon.API.Subscriptions { static func pushEndpointURL(domain: String) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("push/subscription") @@ -116,7 +116,7 @@ extension Mastodon.API.Notification { } } -extension Mastodon.API.Notification { +extension Mastodon.API.Subscriptions { public struct CreateSubscriptionQuery: Codable, PostQuery { let endpoint: String let p256dh: String diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index d04071e9d..1a4496ed3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -115,7 +115,7 @@ extension Mastodon.API { public enum Trends { } public enum Suggestions { } public enum Notifications { } - public enum Notification { } + public enum Subscriptions { } } extension Mastodon.API { From db3d16be4162c21bc20d0ddeeac1455d86db6029 Mon Sep 17 00:00:00 2001 From: ihugo Date: Tue, 13 Apr 2021 17:53:18 +0800 Subject: [PATCH 228/400] fix: use readable guider to layout section header --- .gitignore | 4 +++- Mastodon.xcodeproj/project.pbxproj | 2 +- .../Settings/View/SettingsSectionHeader.swift | 16 +++++++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index a766fc629..24e748a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -120,4 +120,6 @@ xcuserdata # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpm,xcode,cocoapods Localization/StringsConvertor/input -Localization/StringsConvertor/output \ No newline at end of file +Localization/StringsConvertor/output +.DS_Store +/Mastodon.xcworkspace/xcshareddata/swiftpm diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2911667bf..f4127506c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -1161,8 +1161,8 @@ 5B90C455262599800002E742 /* Settings */ = { isa = PBXGroup; children = ( - 5B90C456262599800002E742 /* SettingsViewModel.swift */, 5B90C457262599800002E742 /* View */, + 5B90C456262599800002E742 /* SettingsViewModel.swift */, 5B90C45D262599800002E742 /* SettingsViewController.swift */, ); path = Settings; diff --git a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift index 67dff9822..ccd7fd875 100644 --- a/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift +++ b/Mastodon/Scene/Settings/View/SettingsSectionHeader.swift @@ -7,6 +7,11 @@ import UIKit +struct GroupedTableViewConstraints { + static let topMargin: CGFloat = 40 + static let bottomMargin: CGFloat = 10 +} + /// section header which supports add a custom view blelow the title class SettingsSectionHeader: UIView { lazy var titleLabel: UILabel = { @@ -21,7 +26,12 @@ class SettingsSectionHeader: UIView { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true - view.layoutMargins = UIEdgeInsets(top: 40, left: 12, bottom: 10, right: 12) + view.layoutMargins = UIEdgeInsets( + top: GroupedTableViewConstraints.topMargin, + left: 0, + bottom: GroupedTableViewConstraints.bottomMargin, + right: 0 + ) view.axis = .vertical return view }() @@ -37,8 +47,8 @@ class SettingsSectionHeader: UIView { addSubview(stackView) NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), - stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.trailingAnchor), + stackView.leadingAnchor.constraint(equalTo: self.readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: self.readableContentGuide.trailingAnchor), stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor), stackView.topAnchor.constraint(equalTo: self.topAnchor), ]) From 5417e4275719f1fd1cc6af86e18a99f6148a8f65 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 13 Apr 2021 19:46:42 +0800 Subject: [PATCH 229/400] feat: implement thread scene --- Localization/app.json | 17 +- Mastodon.xcodeproj/project.pbxproj | 56 ++++ Mastodon/Coordinator/SceneCoordinator.swift | 7 + Mastodon/Diffiable/Item/Item.swift | 49 ++- .../Diffiable/Section/StatusSection.swift | 85 +++++- Mastodon/Extension/CGImage.swift | 2 +- Mastodon/Extension/UIBarButtonItem.swift | 1 - Mastodon/Extension/UIImage.swift | 17 +- Mastodon/Generated/Strings.swift | 30 ++ ...Provider+StatusTableViewCellDelegate.swift | 16 +- .../StatusProvider+UITableViewDelegate.swift | 8 +- .../StatusProvider/StatusProvider.swift | 2 +- .../StatusProvider/StatusProviderFacade.swift | 52 +++- .../StatusTableViewControllerAspect.swift | 16 +- .../Contents.json | 6 +- .../system.background.colorset/Contents.json | 6 +- .../Contents.json | 6 +- .../Contents.json | 12 +- .../Contents.json | 6 +- .../Resources/en.lproj/Localizable.strings | 7 + ...imelineViewController+StatusProvider.swift | 4 +- .../HashtagTimelineViewModel+Diffable.swift | 3 +- ...meTimelineViewController+DebugAction.swift | 19 ++ ...imelineViewController+StatusProvider.swift | 4 +- .../HomeTimelineViewController.swift | 41 +-- .../HomeTimelineViewModel+Diffable.swift | 5 +- .../Profile/CachedProfileViewModel.swift | 4 +- ...avoriteViewController+StatusProvider.swift | 4 +- .../Favorite/FavoriteViewModel+Diffable.swift | 3 +- .../Profile/RemoteProfileViewModel.swift | 6 +- ...imelineViewController+StatusProvider.swift | 4 +- .../Timeline/UserTimelineViewController.swift | 4 + .../UserTimelineViewModel+Diffable.swift | 3 +- ...imelineViewController+StatusProvider.swift | 4 +- .../PublicTimelineViewModel+Diffable.swift | 3 +- .../Share/View/Content/ThreadMetaView.swift | 89 ++++++ ...ptiveUserInterfaceStyleBarButtonItem.swift | 76 +++++ .../TableviewCell/StatusTableViewCell.swift | 84 +++++- .../ThreadReplyLoaderTableViewCell.swift | 124 ++++++++ .../TimelineLoaderTableViewCell.swift | 10 +- .../TimelineMiddleLoaderTableViewCell.swift | 28 +- .../TimelineTopLoaderTableViewCell.swift | 36 +++ .../Scene/Thread/CachedThreadViewModel.swift | 15 + .../Scene/Thread/RemoteThreadViewModel.swift | 50 ++++ .../ThreadViewController+StatusProvider.swift | 88 ++++++ .../Scene/Thread/ThreadViewController.swift | 210 +++++++++++++ .../Thread/ThreadViewModel+Diffable.swift | 186 ++++++++++++ .../ThreadViewModel+LoadThreadState.swift | 127 ++++++++ Mastodon/Scene/Thread/ThreadViewModel.swift | 279 ++++++++++++++++++ .../APIService/APIService+Thread.swift | 57 ++++ .../Persist/APIService+Persist+Status.swift | 7 + .../API/Mastodon+API+Statuses.swift | 43 +++ 52 files changed, 1911 insertions(+), 110 deletions(-) create mode 100644 Mastodon/Scene/Share/View/Content/ThreadMetaView.swift create mode 100644 Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift create mode 100644 Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift create mode 100644 Mastodon/Scene/Thread/CachedThreadViewModel.swift create mode 100644 Mastodon/Scene/Thread/RemoteThreadViewModel.swift create mode 100644 Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift create mode 100644 Mastodon/Scene/Thread/ThreadViewController.swift create mode 100644 Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift create mode 100644 Mastodon/Scene/Thread/ThreadViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+Thread.swift diff --git a/Localization/app.json b/Localization/app.json index f29c588c2..e0359caa3 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -89,7 +89,8 @@ "timeline": { "loader": { "load_missing_posts": "Load missing posts", - "loading_missing_posts": "Loading missing posts..." + "loading_missing_posts": "Loading missing posts...", + "show_more_replies": "Show more replies" }, "header": { "no_status_found": "No Status Found", @@ -198,7 +199,7 @@ }, "confirm_email": { "title": "One last thing.", - "subtitle": "We just sent an email to %@,\ntap the link to confirm your account.", + "subtitle": "We just sent an email to %s,\ntap the link to confirm your account.", "button": { "open_email_app": "Open Email App", "dont_receive_email": "I never got an email" @@ -312,6 +313,18 @@ }, "favorite": { "title": "Your Favorites" + }, + "thread": { + "back_title": "Post", + "title": "Post from %s", + "reblog": { + "single": "%s reblog", + "multiple": "%s reblogs" + }, + "favorite": { + "single": "%s favorite", + "multiple": "%s favorites" + } } } } \ No newline at end of file diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 01b40a9c1..6076f2eee 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -135,6 +135,8 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB0140BC25C40D7500F9F3CF /* CommonOSLog */; }; DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140CE25C42AEE00F9F3CF /* OSLog.swift */; }; + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */; }; + DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; @@ -254,6 +256,15 @@ DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */; }; DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */; }; DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */; }; + DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */; }; + DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */; }; + DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */; }; + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */; }; + DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; }; + DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */; }; + DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; }; + DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */; }; + DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337E25C9452D00AD9700 /* APIService+APIError.swift */; }; @@ -297,6 +308,7 @@ DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525632612C988002F1F29 /* MeProfileViewModel.swift */; }; DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */; }; DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */; }; + DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB9759B262462E1004620BD /* ThreadMetaView.swift */; }; DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */; }; DBC7A672260C897100E57475 /* StatusContentWarningEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */; }; DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */; }; @@ -506,6 +518,8 @@ DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; DB0140CE25C42AEE00F9F3CF /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadReplyLoaderTableViewCell.swift; sourceTree = ""; }; + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -633,6 +647,15 @@ DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineIndex.swift; sourceTree = ""; }; DB9282B125F3222800823B15 /* PickServerEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerEmptyStateView.swift; sourceTree = ""; }; DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionTableViewCell.swift; sourceTree = ""; }; + DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewController.swift; sourceTree = ""; }; + DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModel.swift; sourceTree = ""; }; + DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedThreadViewModel.swift; sourceTree = ""; }; + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteThreadViewModel.swift; sourceTree = ""; }; + DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; }; + DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = ""; }; + DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; }; + DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+StatusProvider.swift"; sourceTree = ""; }; + DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; DB98337E25C9452D00AD9700 /* APIService+APIError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+APIError.swift"; sourceTree = ""; }; @@ -675,6 +698,7 @@ DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = ""; }; DBB5256D2612D5A1002F1F29 /* ProfileStatusDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardView.swift; sourceTree = ""; }; DBB525842612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusDashboardMeterView.swift; sourceTree = ""; }; + DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = ""; }; DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = ""; }; DBC7A671260C897100E57475 /* StatusContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentWarningEditorView.swift; sourceTree = ""; }; DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPublishService.swift; sourceTree = ""; }; @@ -847,6 +871,7 @@ DB87D44A2609C11900D12C0D /* PollOptionView.swift */, DBE3CDCE261C42ED00430CC6 /* TimelineHeaderView.swift */, 0F1E2D0A2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift */, + DBB9759B262462E1004620BD /* ThreadMetaView.swift */, ); path = Content; sourceTree = ""; @@ -1051,9 +1076,11 @@ children = ( 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */, 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */, + DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */, 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */, 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */, DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */, + DB02CDAA26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift */, DB92CF7125E7BB98002C1017 /* PollOptionTableViewCell.swift */, ); path = TableviewCell; @@ -1281,6 +1308,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, + DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, @@ -1523,6 +1551,7 @@ DB9D6BFD25E4F57B0051B173 /* Notification */, DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, + DB938EEB2623F52600E5B6C1 /* Thread */, ); path = Scene; sourceTree = ""; @@ -1563,6 +1592,20 @@ path = Extension; sourceTree = ""; }; + DB938EEB2623F52600E5B6C1 /* Thread */ = { + isa = PBXGroup; + children = ( + DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, + DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */, + DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, + DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, + DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, + DB938F0226240EA300E5B6C1 /* CachedThreadViewModel.swift */, + DB938F0826240F3C00E5B6C1 /* RemoteThreadViewModel.swift */, + ); + path = Thread; + sourceTree = ""; + }; DB98335F25C93B0400AD9700 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -1665,6 +1708,7 @@ children = ( DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */, DB59F11725EFA35B001F1DAB /* StripProgressView.swift */, + DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */, ); path = Control; sourceTree = ""; @@ -2164,6 +2208,7 @@ 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, + DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, DBB5255E2611F07A002F1F29 /* ProfileViewModel.swift in Sources */, 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, @@ -2172,6 +2217,7 @@ 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */, DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, + DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, @@ -2184,6 +2230,7 @@ DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, + DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -2203,6 +2250,7 @@ DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, + DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, @@ -2226,6 +2274,7 @@ DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, + DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, @@ -2291,8 +2340,10 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, + DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */, + DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, @@ -2310,11 +2361,13 @@ DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D34D9CB261489930081BFC0 /* SearchViewController+RecomendView.swift in Sources */, + DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, + DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, @@ -2359,6 +2412,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, + DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, 2D69CFF425CA9E2200C3A1B2 /* LoadMoreConfigurableTableViewContainer.swift in Sources */, DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */, DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */, @@ -2379,7 +2433,9 @@ 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 36d42745e..8874a69e8 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -51,6 +51,9 @@ extension SceneCoordinator { // compose case compose(viewModel: ComposeViewModel) + // thread + case thread(viewModel: ThreadViewModel) + // Hashtag Timeline case hashtagTimeline(viewModel: HashtagTimelineViewModel) @@ -226,6 +229,10 @@ private extension SceneCoordinator { let _viewController = ComposeViewController() _viewController.viewModel = viewModel viewController = _viewController + case .thread(let viewModel): + let _viewController = ThreadViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .hashtagTimeline(let viewModel): let _viewController = HashtagTimelineViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index 9f82f6ca5..da3455201 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -14,6 +14,12 @@ import MastodonSDK enum Item { // timeline case homeTimelineIndex(objectID: NSManagedObjectID, attribute: StatusAttribute) + + // thread + case root(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case reply(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case leaf(statusObjectID: NSManagedObjectID, attribute: StatusAttribute) + case leafBottomLoader(statusObjectID: NSManagedObjectID) // normal list case status(objectID: NSManagedObjectID, attribute: StatusAttribute) @@ -21,6 +27,7 @@ enum Item { // loader case homeMiddleLoader(upperTimelineIndexAnchorObjectID: NSManagedObjectID) case publicMiddleLoader(statusID: String) + case topLoader case bottomLoader case emptyStateHeader(attribute: EmptyStateHeaderAttribute) @@ -35,13 +42,16 @@ extension Item { class StatusAttribute: StatusContentWarningAttribute { var isStatusTextSensitive: Bool? var isStatusSensitive: Bool? + var isSeparatorLineHidden: Bool init( isStatusTextSensitive: Bool? = nil, - isStatusSensitive: Bool? = nil + isStatusSensitive: Bool? = nil, + isSeparatorLineHidden: Bool = false ) { self.isStatusTextSensitive = isStatusTextSensitive self.isStatusSensitive = isStatusSensitive + self.isSeparatorLineHidden = isSeparatorLineHidden } // delay attribute init @@ -59,6 +69,23 @@ extension Item { } } +// class LeafAttribute { +// let identifier = UUID() +// let statusID: Status.ID +// var level: Int = 0 +// var hasReply: Bool = true +// +// init( +// statusID: Status.ID, +// level: Int, +// hasReply: Bool = true +// ) { +// self.statusID = statusID +// self.level = level +// self.hasReply = hasReply +// } +// } + class EmptyStateHeaderAttribute: Hashable { let id = UUID() let reason: Reason @@ -99,12 +126,22 @@ extension Item: Equatable { switch (lhs, rhs) { case (.homeTimelineIndex(let objectIDLeft, _), .homeTimelineIndex(let objectIDRight, _)): return objectIDLeft == objectIDRight + case (.root(let objectIDLeft, _), .root(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.reply(let objectIDLeft, _), .reply(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.leaf(let objectIDLeft, _), .leaf(let objectIDRight, _)): + return objectIDLeft == objectIDRight + case (.leafBottomLoader(let objectIDLeft), .leafBottomLoader(let objectIDRight)): + return objectIDLeft == objectIDRight case (.status(let objectIDLeft, _), .status(let objectIDRight, _)): return objectIDLeft == objectIDRight case (.homeMiddleLoader(let upperLeft), .homeMiddleLoader(let upperRight)): return upperLeft == upperRight case (.publicMiddleLoader(let upperLeft), .publicMiddleLoader(let upperRight)): return upperLeft == upperRight + case (.topLoader, .topLoader): + return true case (.bottomLoader, .bottomLoader): return true case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): @@ -120,6 +157,14 @@ extension Item: Hashable { switch self { case .homeTimelineIndex(let objectID, _): hasher.combine(objectID) + case .root(let objectID, _): + hasher.combine(objectID) + case .reply(let objectID, _): + hasher.combine(objectID) + case .leaf(let objectID, _): + hasher.combine(objectID) + case .leafBottomLoader(let objectID): + hasher.combine(objectID) case .status(let objectID, _): hasher.combine(objectID) case .homeMiddleLoader(upperTimelineIndexAnchorObjectID: let upper): @@ -128,6 +173,8 @@ extension Item: Hashable { case .publicMiddleLoader(let upper): hasher.combine(String(describing: Item.publicMiddleLoader.self)) hasher.combine(upper) + case .topLoader: + hasher.combine(String(describing: Item.topLoader.self)) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) case .emptyStateHeader(let attribute): diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fe720e0f0..d0f93921a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -22,9 +22,16 @@ extension StatusSection { managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher, statusTableViewCellDelegate: StatusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate? ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak statusTableViewCellDelegate, weak timelineMiddleLoaderTableViewCellDelegate] tableView, indexPath, item -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [ + weak dependency, + weak statusTableViewCellDelegate, + weak timelineMiddleLoaderTableViewCellDelegate, + weak threadReplyLoaderTableViewCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } guard let statusTableViewCellDelegate = statusTableViewCellDelegate else { return UITableViewCell() } switch item { @@ -46,7 +53,10 @@ extension StatusSection { } cell.delegate = statusTableViewCellDelegate return cell - case .status(let objectID, let attribute): + case .status(let objectID, let attribute), + .root(let objectID, let attribute), + .reply(let objectID, let attribute), + .leaf(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" @@ -62,8 +72,30 @@ extension StatusSection { requestUserID: requestUserID, statusItemAttribute: attribute ) + + switch item { + case .root: + StatusSection.configureThreadMeta(cell: cell, status: status) + ManagedObjectObserver.observe(object: status.reblog ?? status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let status = object as? Status else { return } + StatusSection.configureThreadMeta(cell: cell, status: status) + } + .store(in: &cell.disposeBag) + default: + break + } } cell.delegate = statusTableViewCellDelegate + + return cell + case .leafBottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell + cell.delegate = threadReplyLoaderTableViewCellDelegate return cell case .publicMiddleLoader(let upperTimelineStatusID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self), for: indexPath) as! TimelineMiddleLoaderTableViewCell @@ -75,6 +107,10 @@ extension StatusSection { cell.delegate = timelineMiddleLoaderTableViewCellDelegate timelineMiddleLoaderTableViewCellDelegate?.configure(cell: cell, upperTimelineStatusID: nil, timelineIndexobjectID: upperTimelineIndexObjectID) return cell + case .topLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.startAnimating() + return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.startAnimating() @@ -288,6 +324,9 @@ extension StatusSection { // toolbar StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + // separator line + cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + // set date let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow @@ -312,6 +351,41 @@ extension StatusSection { } .store(in: &cell.disposeBag) } + + static func configureThreadMeta( + cell: StatusTableViewCell, + status: Status + ) { + cell.selectionStyle = .none + cell.threadMetaView.dateLabel.text = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: status.createdAt) + }() + let reblogCountTitle: String = { + let count = status.reblogsCount.intValue + if count > 1 { + return L10n.Scene.Thread.Reblog.multiple(String(count)) + } else { + return L10n.Scene.Thread.Reblog.single(String(count)) + } + }() + cell.threadMetaView.reblogButton.setTitle(reblogCountTitle, for: .normal) + + let favoriteCountTitle: String = { + let count = status.favouritesCount.intValue + if count > 1 { + return L10n.Scene.Thread.Favorite.multiple(String(count)) + } else { + return L10n.Scene.Thread.Favorite.single(String(count)) + } + }() + cell.threadMetaView.favoriteButton.setTitle(favoriteCountTitle, for: .normal) + + cell.threadMetaView.isHidden = false + } + static func configureHeader( cell: StatusTableViewCell, @@ -325,10 +399,13 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() - } else if let replyTo = status.replyTo { + } else if status.inReplyToID != nil { cell.statusView.headerContainerStackView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { + guard let replyTo = status.replyTo else { + return L10n.Common.Controls.Status.userRepliedTo("-") + } let author = replyTo.author let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userRepliedTo(name) diff --git a/Mastodon/Extension/CGImage.swift b/Mastodon/Extension/CGImage.swift index 252f1289a..cced4abee 100644 --- a/Mastodon/Extension/CGImage.swift +++ b/Mastodon/Extension/CGImage.swift @@ -26,7 +26,7 @@ extension CGImage { let pointer = CFDataGetBytePtr(data) else { return nil } let length = CFDataGetLength(data) - guard length > 0 else { return nil} + guard length > 0 else { return nil } var luma: CGFloat = 0.0 for i in stride(from: 0, to: length, by: 4) { diff --git a/Mastodon/Extension/UIBarButtonItem.swift b/Mastodon/Extension/UIBarButtonItem.swift index 8a0630f03..cf1f84e97 100644 --- a/Mastodon/Extension/UIBarButtonItem.swift +++ b/Mastodon/Extension/UIBarButtonItem.swift @@ -17,4 +17,3 @@ extension UIBarButtonItem { } } - diff --git a/Mastodon/Extension/UIImage.swift b/Mastodon/Extension/UIImage.swift index 072a3d4d4..35766c0bc 100644 --- a/Mastodon/Extension/UIImage.swift +++ b/Mastodon/Extension/UIImage.swift @@ -59,7 +59,7 @@ extension UIImage { } } -public extension UIImage { +extension UIImage { func withRoundedCorners(radius: CGFloat? = nil) -> UIImage? { let maxRadius = min(size.width, size.height) / 2 let cornerRadius: CGFloat = { @@ -75,3 +75,18 @@ public extension UIImage { } } } + +extension UIImage { + static func adaptiveUserInterfaceStyleImage(lightImage: UIImage, darkImage: UIImage) -> UIImage { + let imageAsset = UIImageAsset() + imageAsset.register(lightImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .light) + ])) + imageAsset.register(darkImage, with: UITraitCollection(traitsFrom: [ + UITraitCollection(displayScale: 1.0), + UITraitCollection(userInterfaceStyle: .dark) + ])) + return imageAsset.image(with: UITraitCollection.current) + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index da27f7a67..4ed28165e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -203,6 +203,8 @@ internal enum L10n { internal static let loadingMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadingMissingPosts") /// Load missing posts internal static let loadMissingPosts = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.LoadMissingPosts") + /// Show more replies + internal static let showMoreReplies = L10n.tr("Localizable", "Common.Controls.Timeline.Loader.ShowMoreReplies") } } } @@ -567,6 +569,34 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Thread { + /// Post + internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") + /// Post from %@ + internal static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Title", String(describing: p1)) + } + internal enum Favorite { + /// %@ favorites + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Multiple", String(describing: p1)) + } + /// %@ favorite + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Favorite.Single", String(describing: p1)) + } + } + internal enum Reblog { + /// %@ reblogs + internal static func multiple(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Multiple", String(describing: p1)) + } + /// %@ reblog + internal static func single(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Thread.Reblog.Single", String(describing: p1)) + } + } + } internal enum Welcome { /// Social networking\nback in your hands. internal static let slogan = L10n.tr("Localizable", "Scene.Welcome.Slogan") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index f8c99c13f..2983a6f96 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -46,9 +46,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let item = item(for: cell, indexPath: nil) else { return } switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusTextSensitive = false - case .status(_, let attribute): + case .homeTimelineIndex(_, let attribute), + .status(_, let attribute), + .root(_, let attribute), + .reply(_, let attribute), + .leaf(_, let attribute): attribute.isStatusTextSensitive = false default: return @@ -81,9 +83,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let item = item(for: cell, indexPath: nil) else { return } switch item { - case .homeTimelineIndex(_, let attribute): - attribute.isStatusSensitive = false - case .status(_, let attribute): + case .homeTimelineIndex(_, let attribute), + .status(_, let attribute), + .root(_, let attribute), + .reply(_, let attribute), + .leaf(_, let attribute): attribute.isStatusSensitive = false default: return diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 32915baf9..cd6cbf589 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -12,9 +12,6 @@ import os.log import UIKit extension StatusTableViewCellDelegate where Self: StatusProvider { - // TODO: - // func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - // } func handleTableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { // update poll when status appear @@ -102,6 +99,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } .store(in: &disposeBag) } + + func handleTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, indexPath: indexPath) + } + } extension StatusTableViewCellDelegate where Self: StatusProvider {} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index e16343ee6..8e27a2207 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -13,7 +13,7 @@ import CoreDataStack protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async func status() -> Future - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future func status(for cell: UICollectionViewCell) -> Future // sync diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index abdc27902..6db861ec6 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -60,6 +60,54 @@ extension StatusProviderFacade { } .store(in: &provider.disposeBag) } + +} + +extension StatusProviderFacade { + + static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, indexPath: IndexPath) { + _coordinateToStatusThreadScene( + for: target, + provider: provider, + status: provider.status(for: nil, indexPath: indexPath) + ) + } + + static func coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, cell: UITableViewCell) { + _coordinateToStatusThreadScene( + for: target, + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _coordinateToStatusThreadScene(for target: Target, provider: StatusProvider, status: Future) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + let _status: Status? = { + switch target { + case .primary: return status?.reblog ?? status // original status + case .secondary: return status // reblog or status + } + }() + guard let status = _status else { return } + + let threadViewModel = CachedThreadViewModel(context: provider.context, status: status) + DispatchQueue.main.async { + if provider.navigationController == nil { + let from = provider.presentingViewController ?? provider + provider.dismiss(animated: true) { + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: from, transition: .show) + } + } else { + provider.coordinator.present(scene: .thread(viewModel: threadViewModel), from: provider, transition: .show) + } + } + } + .store(in: &provider.disposeBag) + } + } extension StatusProviderFacade { @@ -339,8 +387,8 @@ extension StatusProviderFacade { extension StatusProviderFacade { enum Target { - case primary // original - case secondary // attachment reblog or reply + case primary // original status + case secondary // wrapper status or reply (when needs. e.g tap header of status view) } } diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index ecd8291ff..77b1e17ba 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -9,10 +9,12 @@ import UIKit import AVKit // Check List Last Updated +// - HomeViewController: 2021/4/13 // - FavoriteViewController: 2021/4/8 // - HashtagTimelineViewController: 2021/4/8 -// - UserTimelineViewController: 2021/4/8 -// * StatusTableViewControllerAspect: 2021/4/7 +// - UserTimelineViewController: 2021/4/13 +// - ThreadViewController: 2021/4/13 +// * StatusTableViewControllerAspect: 2021/4/12 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -69,7 +71,7 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } -// [B4] StatusTableViewControllerAspect.aspectTableView(_:didEndDisplaying:forRowAt:) +// [B4] aspectTableView(_:didEndDisplaying:forRowAt:) extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { /// [Media] hook to notify video service func aspectTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { @@ -93,6 +95,14 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } +// [B5] aspectTableView(_:didSelectRowAt:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + /// [UI] hook to coordinator to thread + func aspectTableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + handleTableView(tableView, didSelectRowAt: indexPath) + } +} + // MARK: - UITableViewDataSourcePrefetching [C] // [C1] aspectTableView(:prefetchRowsAt) diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index 55f84c267..3338422aa 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index 55f84c267..6b372a191 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFE", - "green" : "0xFF", - "red" : "0xFE" + "blue" : "254", + "green" : "255", + "red" : "254" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index 6bce2b697..6d9833e91 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index d8f32572f..5da572b1d 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFF", - "green" : "0xFF", - "red" : "0xFF" + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json index d47050048..5da572b1d 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2B", - "green" : "0x23", - "red" : "0x1F" + "blue" : "0x3C", + "green" : "0x3A", + "red" : "0x3A" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e94101cc4..6914b608c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -68,6 +68,7 @@ Your account looks like this to them."; "Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; "Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; "Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be @@ -181,5 +182,11 @@ any server."; "Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; "Scene.ServerRules.TermsOfService" = "terms of service"; "Scene.ServerRules.Title" = "Some ground rules."; +"Scene.Thread.BackTitle" = "Post"; +"Scene.Thread.Favorite.Multiple" = "%@ favorites"; +"Scene.Thread.Favorite.Single" = "%@ favorite"; +"Scene.Thread.Reblog.Multiple" = "%@ reblogs"; +"Scene.Thread.Reblog.Single" = "%@ reblog"; +"Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift index 23068b7bc..191ad374d 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension HashtagTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index 26f32a33c..ed7b3a844 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -28,7 +28,8 @@ extension HashtagTimelineViewModel { managedObjectContext: context.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 0c43af79e..401e4fc14 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -33,6 +33,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showProfileAction(action) }, + UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -304,5 +308,20 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + @objc private func showThreadAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift index 9e1915301..aea931a62 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension HomeTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 1f3dea81a..f4248ad34 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -179,6 +179,8 @@ extension HomeTimelineViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + aspectViewWillAppear(animated) + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } @@ -198,8 +200,8 @@ extension HomeTimelineViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - context.videoPlaybackService.viewDidDisappear(from: self) - context.audioPlaybackService.viewDidDisappear(from: self) + + aspectViewDidDisappear(animated) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -262,11 +264,19 @@ extension HomeTimelineViewController { } +// MARK: - StatusTableViewControllerAspect +extension HomeTimelineViewController: StatusTableViewControllerAspect { } + +extension HomeTimelineViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { return viewModel.cellFrameCache } +} + // MARK: - UIScrollViewDelegate extension HomeTimelineViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { - handleScrollViewDidScroll(scrollView) - self.viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) + + aspectScrollViewDidScroll(scrollView) + viewModel.homeTimelineNavigationBarTitleViewModel.handleScrollViewDidScroll(scrollView) } } @@ -281,32 +291,26 @@ extension HomeTimelineViewController: LoadMoreConfigurableTableViewContainer { extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 200 - // TODO: - // guard let diffableDataSource = viewModel.diffableDataSource else { return 100 } - // guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return 100 } - // - // guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { - // return 200 - // } - // // os_log("%{public}s[%{public}ld], %{public}s: cache cell frame %s", ((#file as NSString).lastPathComponent), #line, #function, frame.debugDescription) - // - // return ceil(frame.height) + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, willDisplay: cell, forRowAt: indexPath) + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) } func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { - handleTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) } } // MARK: - UITableViewDataSourcePrefetching extension HomeTimelineViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { - handleTableView(tableView, prefetchRowsAt: indexPaths) + aspectTableView(tableView, prefetchRowsAt: indexPaths) } } @@ -317,7 +321,6 @@ extension HomeTimelineViewController: ContentOffsetAdjustableTimelineViewControl } } - // MARK: - TimelineMiddleLoaderTableViewCellDelegate extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate { func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID: NSManagedObjectID?) { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 5f16a18eb..6f5e66c0e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -29,7 +29,8 @@ extension HomeTimelineViewModel { managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) // var snapshot = NSDiffableDataSourceSnapshot() @@ -88,6 +89,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { for (i, timelineIndex) in timelineIndexes.enumerated() { let attribute = oldSnapshotAttributeDict[timelineIndex.objectID] ?? Item.StatusAttribute() + attribute.isSeparatorLineHidden = false // append new item into snapshot newTimelineItems.append(.homeTimelineIndex(objectID: timelineIndex.objectID, attribute: attribute)) @@ -96,6 +98,7 @@ extension HomeTimelineViewModel: NSFetchedResultsControllerDelegate { switch (isLast, timelineIndex.hasMore) { case (false, true): newTimelineItems.append(.homeMiddleLoader(upperTimelineIndexAnchorObjectID: timelineIndex.objectID)) + attribute.isSeparatorLineHidden = true case (true, true): shouldAddBottomLoader = true default: diff --git a/Mastodon/Scene/Profile/CachedProfileViewModel.swift b/Mastodon/Scene/Profile/CachedProfileViewModel.swift index 0e5823d09..083724be1 100644 --- a/Mastodon/Scene/Profile/CachedProfileViewModel.swift +++ b/Mastodon/Scene/Profile/CachedProfileViewModel.swift @@ -10,8 +10,8 @@ import CoreDataStack final class CachedProfileViewModel: ProfileViewModel { - convenience init(context: AppContext, mastodonUser: MastodonUser) { - self.init(context: context, optionalMastodonUser: mastodonUser) + init(context: AppContext, mastodonUser: MastodonUser) { + super.init(context: context, optionalMastodonUser: mastodonUser) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift index 2dadc8545..68adc1e3e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension FavoriteViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index e64df2c99..85928e852 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension FavoriteViewModel { managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil ) // set empty section to make update animation top-to-bottom style diff --git a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift index c480e6fc9..153f50998 100644 --- a/Mastodon/Scene/Profile/RemoteProfileViewModel.swift +++ b/Mastodon/Scene/Profile/RemoteProfileViewModel.swift @@ -12,8 +12,8 @@ import MastodonSDK final class RemoteProfileViewModel: ProfileViewModel { - convenience init(context: AppContext, userID: Mastodon.Entity.Account.ID) { - self.init(context: context, optionalMastodonUser: nil) + init(context: AppContext, userID: Mastodon.Entity.Account.ID) { + super.init(context: context, optionalMastodonUser: nil) guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return @@ -47,8 +47,6 @@ final class RemoteProfileViewModel: ProfileViewModel { self.mastodonUser.value = mastodonUser } .store(in: &disposeBag) - } - } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift index 1ea164406..4fc857812 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift @@ -18,14 +18,14 @@ extension UserTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index e8e71ccf4..88a98b589 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -124,6 +124,10 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 1a09e1b35..8e6f1314f 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension UserTimelineViewModel { managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: nil + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: nil ) // set empty section to make update animation top-to-bottom style diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift index a92b8f37e..04fc526a0 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift @@ -19,14 +19,14 @@ extension PublicTimelineViewController: StatusProvider { return Future { promise in promise(.success(nil)) } } - func status(for cell: UITableViewCell, indexPath: IndexPath?) -> Future { + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { return Future { promise in guard let diffableDataSource = self.viewModel.diffableDataSource else { assertionFailure() promise(.success(nil)) return } - guard let indexPath = indexPath ?? self.tableView.indexPath(for: cell), + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), let item = diffableDataSource.itemIdentifier(for: indexPath) else { promise(.success(nil)) return diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index ce0e8b19d..3ca407caa 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -28,7 +28,8 @@ extension PublicTimelineViewModel { managedObjectContext: fetchedResultsController.managedObjectContext, timestampUpdatePublisher: timestampUpdatePublisher, statusTableViewCellDelegate: statusTableViewCellDelegate, - timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate + timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: nil ) items.value = [] stateMachine.enter(PublicTimelineViewModel.State.Loading.self) diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift new file mode 100644 index 000000000..16d1b04a6 --- /dev/null +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -0,0 +1,89 @@ +// +// ThreadMetaView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit + +final class ThreadMetaView: UIView { + + let dateLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.text = "Date" + return label + }() + + let reblogButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 reblog", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + let favoriteButton: UIButton = { + let button = UIButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle("0 favorite", for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitleColor(Asset.Colors.Button.normal.color.withAlphaComponent(0.5), for: .highlighted) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension ThreadMetaView { + private func _init() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 20 + + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12), + ]) + + stackView.addArrangedSubview(dateLabel) + stackView.addArrangedSubview(reblogButton) + stackView.addArrangedSubview(favoriteButton) + + dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) + favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ThreadMetaView_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + ThreadMetaView() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift new file mode 100644 index 000000000..e801d1756 --- /dev/null +++ b/Mastodon/Scene/Share/View/Control/AdaptiveUserInterfaceStyleBarButtonItem.swift @@ -0,0 +1,76 @@ +// +// AdaptiveUserInterfaceStyleBarButtonItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import UIKit + +final class AdaptiveUserInterfaceStyleBarButtonItem: UIBarButtonItem { + + let button = AdaptiveCustomButton() + + init(lightImage: UIImage, darkImage: UIImage) { + super.init() + button.setImage(light: lightImage, dark: darkImage) + self.customView = button + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + +} + +extension AdaptiveUserInterfaceStyleBarButtonItem { + class AdaptiveCustomButton: UIButton { + + var lightImage: UIImage? + var darkImage: UIImage? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + private func _init() { + adjustsImageWhenHighlighted = false + } + + override var isHighlighted: Bool { + didSet { + alpha = isHighlighted ? 0.6 : 1 + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + resetImage() + } + + func setImage(light: UIImage, dark: UIImage) { + lightImage = light + darkImage = dark + resetImage() + } + + private func resetImage() { + switch traitCollection.userInterfaceStyle { + case .light: + setImage(lightImage, for: .normal) + case .dark, + .unspecified: + setImage(darkImage, for: .normal) + @unknown default: + assertionFailure() + } + } + + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index b600924a6..39916741e 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -56,14 +56,25 @@ final class StatusTableViewCell: UITableViewCell { var observations = Set() let statusView = StatusView() - + let threadMetaStackView = UIStackView() + let threadMetaView = ThreadMetaView() + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + override func prepareForReuse() { super.prepareForReuse() + selectionStyle = .default statusView.isStatusTextSensitive = false statusView.cleanUpContentWarning() statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true + threadMetaView.isHidden = true disposeBag.removeAll() observations.removeAll() } @@ -90,7 +101,6 @@ final class StatusTableViewCell: UITableViewCell { extension StatusTableViewCell { private func _init() { - selectionStyle = .none backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color @@ -102,24 +112,74 @@ extension StatusTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), ]) - let bottomPaddingView = UIView() - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(bottomPaddingView) + threadMetaStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(threadMetaStackView) NSLayoutConstraint.activate([ - bottomPaddingView.topAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - bottomPaddingView.heightAnchor.constraint(equalToConstant: StatusTableViewCell.bottomPaddingHeight).priority(.defaultHigh), + threadMetaStackView.topAnchor.constraint(equalTo: statusView.bottomAnchor), + threadMetaStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + threadMetaStackView.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + threadMetaStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) - bottomPaddingView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - + threadMetaStackView.addArrangedSubview(threadMetaView) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + statusView.delegate = self statusView.pollTableView.delegate = self statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.delegate = self + + // default hidden + threadMetaView.isHidden = true } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + +} + +extension StatusTableViewCell { + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } } // MARK: - UITableViewDelegate diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift new file mode 100644 index 000000000..10ad0c5c8 --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -0,0 +1,124 @@ +// +// ThreadReplyLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-13. +// + +import os.log +import UIKit +import Combine + +protocol ThreadReplyLoaderTableViewCellDelegate: class { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) +} + +final class ThreadReplyLoaderTableViewCell: UITableViewCell { + + static let cellHeight: CGFloat = 44 + + weak var delegate: ThreadReplyLoaderTableViewCellDelegate? + + let loadMoreButton: UIButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) + button.setTitle(L10n.Common.Controls.Timeline.Loader.showMoreReplies, for: .normal) + return button + }() + + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + +} + +extension ThreadReplyLoaderTableViewCell { + + func _init() { + selectionStyle = .none + backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + + loadMoreButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(loadMoreButton) + NSLayoutConstraint.activate([ + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor), + loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor), + loadMoreButton.heightAnchor.constraint(equalToConstant: ThreadReplyLoaderTableViewCell.cellHeight).priority(.required - 1), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + + loadMoreButton.addTarget(self, action: #selector(ThreadReplyLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) + } + + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } + +} + +extension ThreadReplyLoaderTableViewCell { + @objc private func loadMoreButtonDidPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.threadReplyLoaderTableViewCell(self, loadMoreButtonDidPressed: sender) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 38bf7ef78..dc144d109 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -10,9 +10,9 @@ import Combine class TimelineLoaderTableViewCell: UITableViewCell { - static let buttonHeight: CGFloat = 62 - static let cellHeight: CGFloat = TimelineLoaderTableViewCell.buttonHeight + 17 - static let extraTopPadding: CGFloat = 10 + static let buttonHeight: CGFloat = 44 + static let buttonMargin: CGFloat = 12 + static let cellHeight: CGFloat = buttonHeight + 2 * buttonMargin static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() @@ -78,10 +78,10 @@ class TimelineLoaderTableViewCell: UITableViewCell { loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) NSLayoutConstraint.activate([ - loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 7), + loadMoreButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), loadMoreButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), contentView.trailingAnchor.constraint(equalTo: loadMoreButton.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: 14), + contentView.bottomAnchor.constraint(equalTo: loadMoreButton.bottomAnchor, constant: TimelineLoaderTableViewCell.buttonMargin), loadMoreButton.heightAnchor.constraint(equalToConstant: TimelineLoaderTableViewCell.buttonHeight).priority(.required - 1), ]) diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 75c06a339..7438f5bfd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -18,11 +18,8 @@ protocol TimelineMiddleLoaderTableViewCellDelegate: class { final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { weak var delegate: TimelineMiddleLoaderTableViewCellDelegate? - let sawToothView: SawToothView = { - let sawToothView = SawToothView() - sawToothView.translatesAutoresizingMaskIntoConstraints = false - return sawToothView - }() + let topSawToothView = SawToothView() + let bottomSawToothView = SawToothView() override func _init() { super._init() @@ -34,12 +31,23 @@ final class TimelineMiddleLoaderTableViewCell: TimelineLoaderTableViewCell { loadMoreButton.setInsets(forContentPadding: .zero, imageTitlePadding: 4) loadMoreButton.addTarget(self, action: #selector(TimelineMiddleLoaderTableViewCell.loadMoreButtonDidPressed(_:)), for: .touchUpInside) - contentView.addSubview(sawToothView) + topSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(topSawToothView) NSLayoutConstraint.activate([ - sawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - sawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - sawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - sawToothView.heightAnchor.constraint(equalToConstant: 3), + topSawToothView.topAnchor.constraint(equalTo: contentView.topAnchor), + topSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + topSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + topSawToothView.heightAnchor.constraint(equalToConstant: 3), + ]) + topSawToothView.transform = CGAffineTransform(scaleX: 1, y: -1) // upside down + + bottomSawToothView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(bottomSawToothView) + NSLayoutConstraint.activate([ + bottomSawToothView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomSawToothView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomSawToothView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + bottomSawToothView.heightAnchor.constraint(equalToConstant: 3), ]) } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift new file mode 100644 index 000000000..4accee1de --- /dev/null +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineTopLoaderTableViewCell.swift @@ -0,0 +1,36 @@ +// +// TimelineTopLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine + +final class TimelineTopLoaderTableViewCell: TimelineLoaderTableViewCell { + override func _init() { + super._init() + + activityIndicatorView.isHidden = false + + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct TimelineTopLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + TimelineTopLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif + diff --git a/Mastodon/Scene/Thread/CachedThreadViewModel.swift b/Mastodon/Scene/Thread/CachedThreadViewModel.swift new file mode 100644 index 000000000..d4866b0bd --- /dev/null +++ b/Mastodon/Scene/Thread/CachedThreadViewModel.swift @@ -0,0 +1,15 @@ +// +// CachedThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import Foundation +import CoreDataStack + +final class CachedThreadViewModel: ThreadViewModel { + init(context: AppContext, status: Status) { + super.init(context: context, optionalStatus: status) + } +} diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift new file mode 100644 index 000000000..e79c355cf --- /dev/null +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -0,0 +1,50 @@ +// +// RemoteThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import CoreDataStack +import MastodonSDK + +final class RemoteThreadViewModel: ThreadViewModel { + + init(context: AppContext, statusID: Mastodon.Entity.Status.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + context.apiService.status( + domain: domain, + statusID: statusID, + authorizationBox: activeMastodonAuthenticationBox + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote status %s fetched", ((#file as NSString).lastPathComponent), #line, #function, statusID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: response.value.id) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift new file mode 100644 index 000000000..05cc6e4b2 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift @@ -0,0 +1,88 @@ +// +// ThreadViewController+StatusProvider.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +// MARK: - StatusProvider +extension ThreadViewController: StatusProvider { + + func status() -> Future { + return Future { promise in promise(.success(nil)) } + } + + func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + return Future { promise in + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + promise(.success(nil)) + return + } + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .root(let statusObjectID, _), + .reply(let statusObjectID, _), + .leaf(let statusObjectID, _): + let managedObjectContext = self.viewModel.context.managedObjectContext + managedObjectContext.perform { + let status = managedObjectContext.object(with: statusObjectID) as? Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + } + + func status(for cell: UICollectionViewCell) -> Future { + return Future { promise in promise(.success(nil)) } + } + + var managedObjectContext: NSManagedObjectContext { + return viewModel.context.managedObjectContext + } + + var tableViewDiffableDataSource: UITableViewDiffableDataSource? { + return viewModel.diffableDataSource + } + + func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return nil + } + + guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + return nil + } + + return item + } + + func items(indexPaths: [IndexPath]) -> [Item] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { + assertionFailure() + return [] + } + + var items: [Item] = [] + for indexPath in indexPaths { + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } + items.append(item) + } + return items + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift new file mode 100644 index 000000000..43c40025e --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -0,0 +1,210 @@ +// +// ThreadViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import AVKit + +final class ThreadViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: ThreadViewModel! + + let titleView = DoubleTitleLabelNavigationBarTitleView() + + let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem( + lightImage: UIImage(systemName: "arrowshape.turn.up.left")!, + darkImage: UIImage(systemName: "arrowshape.turn.up.left.fill")! + ) + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: String(describing: StatusTableViewCell.self)) + tableView.register(TimelineMiddleLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineMiddleLoaderTableViewCell.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(ThreadReplyLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + + return tableView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + navigationItem.title = L10n.Scene.Thread.backTitle + navigationItem.titleView = titleView + navigationItem.rightBarButtonItem = replyBarButtonItem + replyBarButtonItem.button.addTarget(self, action: #selector(ThreadViewController.replyBarButtonItemPressed(_:)), for: .touchUpInside) + + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + tableView.delegate = self + tableView.prefetchDataSource = self + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self, + statusTableViewCellDelegate: self, + threadReplyLoaderTableViewCellDelegate: self + ) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + viewModel.navigationBarTitle + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.titleView.update(title: title ?? L10n.Scene.Thread.backTitle, subtitle: nil) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // force readable layout frame update + tableView.reloadData() + aspectViewWillAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + aspectViewDidDisappear(animated) + } + +} + +extension ThreadViewController { + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + } +} + +// MARK: - StatusTableViewControllerAspect +extension ThreadViewController: StatusTableViewControllerAspect { } + +// MARK: - TableViewCellHeightCacheableContainer +extension ThreadViewController: TableViewCellHeightCacheableContainer { + var cellFrameCache: NSCache { viewModel.cellFrameCache } +} + +// MARK: - UITableViewDelegate +extension ThreadViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + aspectTableView(tableView, estimatedHeightForRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, willDisplay: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil } + + // disable root selection + switch item { + case .root: + return nil + default: + return indexPath + } + } + +} + +// MARK: - UITableViewDataSourcePrefetching +extension ThreadViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, prefetchRowsAt: indexPaths) + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension ThreadViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + +// MARK: - AVPlayerViewControllerDelegate +extension ThreadViewController: AVPlayerViewControllerDelegate { + + func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willBeginFullScreenPresentationWithAnimationCoordinator: coordinator) + } + + func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { + aspectPlayerViewController(playerViewController, willEndFullScreenPresentationWithAnimationCoordinator: coordinator) + } + +} + +// MARK: - statusTableViewCellDelegate +extension ThreadViewController: StatusTableViewCellDelegate { + weak var playerViewControllerDelegate: AVPlayerViewControllerDelegate? { return self } + func parent() -> UIViewController { return self } +} + +// MARK: - ThreadReplyLoaderTableViewCellDelegate +extension ThreadViewController: ThreadReplyLoaderTableViewCellDelegate { + func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + guard case let .leafBottomLoader(statusObjectID) = item else { return } + + let nodes = viewModel.descendantNodes.value + nodes.forEach { node in + expandReply(node: node, statusObjectID: statusObjectID) + } + viewModel.descendantNodes.value = nodes + } + + private func expandReply(node: ThreadViewModel.LeafNode, statusObjectID: NSManagedObjectID) { + if node.objectID == statusObjectID { + node.isChildrenExpanded = true + } else { + for child in node.children { + expandReply(node: child, statusObjectID: statusObjectID) + } + } + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift new file mode 100644 index 000000000..323a7a545 --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -0,0 +1,186 @@ +// +// ThreadViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import UIKit +import Combine +import CoreData + +extension ThreadViewModel { + + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + statusTableViewCellDelegate: StatusTableViewCellDelegate, + threadReplyLoaderTableViewCellDelegate: ThreadReplyLoaderTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = StatusSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: context.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + threadReplyLoaderTableViewCellDelegate: threadReplyLoaderTableViewCellDelegate + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + if let rootNode = self.rootNode.value, rootNode.replyToID != nil { + snapshot.appendItems([.topLoader], toSection: .main) + } + + diffableDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + + Publishers.CombineLatest3( + rootItem, + ancestorItems, + descendantItems + ) + .debounce(for: .milliseconds(100), scheduler: DispatchQueue.main) // some magic to avoid jitter + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem, ancestorItems, descendantItems in + guard let self = self else { return } + guard let tableView = self.tableView, + let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() + else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + let oldSnapshot = diffableDataSource.snapshot() + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + + let currentState = self.loadThreadStateMachine.currentState + + // reply to + if self.rootNode.value?.replyToID != nil, !(currentState is LoadThreadState.NoMore) { + newSnapshot.appendItems([.topLoader], toSection: .main) + } + newSnapshot.appendItems(ancestorItems, toSection: .main) + + // root + if let rootItem = rootItem { + switch rootItem { + case .root: + newSnapshot.appendItems([rootItem], toSection: .main) + default: + break + } + } + + // leaf + if !(currentState is LoadThreadState.NoMore) { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } + newSnapshot.appendItems(descendantItems, toSection: .main) + + // difference for first visiable item exclude .topLoader + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + return + } + + // addtional margin for .topLoader + let oldTopMargin: CGFloat = { + let marginHeight = TimelineTopLoaderTableViewCell.cellHeight + if oldSnapshot.itemIdentifiers.contains(.topLoader) { + return marginHeight + } + if !ancestorItems.isEmpty { + return marginHeight + } + + return .zero + }() + + let oldRootCell: UITableViewCell? = { + guard let rootItem = rootItem else { return nil } + guard let index = oldSnapshot.indexOfItem(rootItem) else { return nil } + guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { return nil } + return cell + }() + // save height before cell reuse + let oldRootCellHeight = oldRootCell?.frame.height + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + guard let _ = rootItem else { + return + } + if let oldRootCellHeight = oldRootCellHeight { + // set bottom inset. Make root item pin to top (with margin). + let bottomSpacing = tableView.safeAreaLayoutGuide.layoutFrame.height - oldRootCellHeight - oldTopMargin + tableView.contentInset.bottom = max(0, bottomSpacing) + } + + // set scroll position + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + let contentOffsetY: CGFloat = { + var offset: CGFloat = tableView.contentOffset.y - difference.offset + if tableView.contentInset.bottom != 0.0 && descendantItems.isEmpty { + // needs restore top margin if bottom inset adjusted AND no descendantItems + offset += oldTopMargin + } + return offset + }() + tableView.setContentOffset(CGPoint(x: 0, y: contentOffsetY), animated: false) + } + } + .store(in: &disposeBag) + } + +} + +extension ThreadViewModel { + private struct Difference { + let item: T + let sourceIndexPath: IndexPath + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + guard let visibleIndexPaths = tableView.indexPathsForVisibleRows?.sorted() else { return nil } + + // find index of the first visible item exclude .topLoader + var _index: Int? + let items = oldSnapshot.itemIdentifiers(inSection: .main) + for (i, item) in items.enumerated() { + if case .topLoader = item { continue } + guard visibleIndexPaths.contains(where: { $0.row == i }) else { continue } + + _index = i + break + } + + guard let index = _index else { return nil } + let sourceIndexPath = IndexPath(row: index, section: 0) + guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + + let item = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] + guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: item) else { return nil } + let targetIndexPath = IndexPath(row: itemIndex, section: 0) + + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) + return Difference( + item: item, + sourceIndexPath: sourceIndexPath, + targetIndexPath: targetIndexPath, + offset: offset + ) + } +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift new file mode 100644 index 000000000..5327edc5c --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -0,0 +1,127 @@ +// +// ThreadViewModel+LoadThreadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import GameplayKit +import CoreDataStack +import MastodonSDK + +extension ThreadViewModel { + class LoadThreadState: GKState { + weak var viewModel: ThreadViewModel? + + init(viewModel: ThreadViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + } + } +} + +extension ThreadViewModel.LoadThreadState { + class Initial: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + } + + class Loading: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: return true + case is NoMore.Type: return true + default: return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let mastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + guard let rootNode = viewModel.rootNode.value else { + stateMachine.enter(Fail.self) + return + } + + // trigger data source update + viewModel.rootItem.value = viewModel.rootItem.value + + let domain = rootNode.domain + let statusID = rootNode.statusID + let replyToID = rootNode.replyToID + + viewModel.context.apiService.statusContext( + domain: domain, + statusID: statusID, + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch status context for %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, statusID, error.localizedDescription) + stateMachine.enter(Fail.self) + case .finished: + break + } + } receiveValue: { response in + stateMachine.enter(NoMore.self) + + viewModel.ancestorNodes.value = ThreadViewModel.ReplyNode.replyToThread( + for: replyToID, + from: response.value.ancestors, + domain: domain, + managedObjectContext: viewModel.context.managedObjectContext + ) + viewModel.descendantNodes.value = ThreadViewModel.LeafNode.tree( + for: rootNode.statusID, + from: response.value.descendants, + domain: domain, + managedObjectContext: viewModel.context.managedObjectContext + ) + } + .store(in: &viewModel.disposeBag) + } + + } + + class Fail: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: return true + default: return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + stateMachine.enter(Loading.self) + } + } + } + + class NoMore: ThreadViewModel.LoadThreadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift new file mode 100644 index 000000000..2f0a9daca --- /dev/null +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -0,0 +1,279 @@ +// +// ThreadViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +class ThreadViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + let rootNode: CurrentValueSubject + let rootItem: CurrentValueSubject + let cellFrameCache = NSCache() + + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + weak var tableView: UITableView? + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var loadThreadStateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + LoadThreadState.Initial(viewModel: self), + LoadThreadState.Loading(viewModel: self), + LoadThreadState.Fail(viewModel: self), + LoadThreadState.NoMore(viewModel: self), + + ]) + stateMachine.enter(LoadThreadState.Initial.self) + return stateMachine + }() + let ancestorNodes = CurrentValueSubject<[ReplyNode], Never>([]) + let ancestorItems = CurrentValueSubject<[Item], Never>([]) + let descendantNodes = CurrentValueSubject<[LeafNode], Never>([]) + let descendantItems = CurrentValueSubject<[Item], Never>([]) + let navigationBarTitle: CurrentValueSubject + + init(context: AppContext, optionalStatus: Status?) { + self.context = context + self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) }) + self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) }) + self.navigationBarTitle = CurrentValueSubject( + optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) } + ) + + rootNode + .receive(on: DispatchQueue.main) + .sink { [weak self] rootNode in + guard let self = self else { return } + guard rootNode != nil else { return } + self.loadThreadStateMachine.enter(LoadThreadState.Loading.self) + } + .store(in: &disposeBag) + + if optionalStatus == nil { + rootItem + .receive(on: DispatchQueue.main) + .sink { [weak self] rootItem in + guard let self = self else { return } + guard case let .root(objectID, _) = rootItem else { return } + self.context.managedObjectContext.perform { + guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else { + return + } + self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID) + self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback) + } + } + .store(in: &disposeBag) + } + + // descendantNodes + + ancestorNodes + .receive(on: DispatchQueue.main) + .compactMap { [weak self] nodes -> [Item]? in + guard let self = self else { return nil } + guard !nodes.isEmpty else { return [] } + + guard let diffableDataSource = self.diffableDataSource else { return nil } + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + switch item { + case .reply(let objectID, let attribute): + oldSnapshotAttributeDict[objectID] = attribute + default: + break + } + } + + var items: [Item] = [] + for node in nodes { + let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute() + items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute)) + } + + return items.reversed() + } + .assign(to: \.value, on: ancestorItems) + .store(in: &disposeBag) + + descendantNodes + .receive(on: DispatchQueue.main) + .compactMap { [weak self] nodes -> [Item]? in + guard let self = self else { return nil } + guard !nodes.isEmpty else { return [] } + + guard let diffableDataSource = self.diffableDataSource else { return nil } + let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + switch item { + case .leaf(let objectID, let attribute): + oldSnapshotAttributeDict[objectID] = attribute + default: + break + } + } + + var items: [Item] = [] + + func buildThread(node: LeafNode) { + let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute() + items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute)) + // only expand the first child + if let firstChild = node.children.first { + if !node.isChildrenExpanded { + items.append(Item.leafBottomLoader(statusObjectID: node.objectID)) + } else { + buildThread(node: firstChild) + } + } + } + + for node in nodes { + buildThread(node: node) + } + return items + } + .assign(to: \.value, on: descendantItems) + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ThreadViewModel { + + struct RootNode { + let domain: String + let statusID: Mastodon.Entity.Status.ID + let replyToID: Mastodon.Entity.Status.ID? + } + + class ReplyNode { + let statusID: Mastodon.Entity.Status.ID + let statusObjectID: NSManagedObjectID + + init(statusID: Mastodon.Entity.Status.ID, statusObjectID: NSManagedObjectID) { + self.statusID = statusID + self.statusObjectID = statusObjectID + } + + static func replyToThread( + for replyToID: Mastodon.Entity.Status.ID?, + from statuses: [Mastodon.Entity.Status], + domain: String, + managedObjectContext: NSManagedObjectContext + ) -> [ReplyNode] { + guard let replyToID = replyToID else { + return [] + } + + var nodes: [ReplyNode] = [] + managedObjectContext.performAndWait { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) + request.fetchLimit = statuses.count + let objects = managedObjectContext.safeFetch(request) + + var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] + for object in objects { + objectDict[object.id] = object + } + var nextID: Mastodon.Entity.Status.ID? = replyToID + while let _nextID = nextID { + guard let object = objectDict[_nextID] else { break } + nodes.append(ThreadViewModel.ReplyNode(statusID: _nextID, statusObjectID: object.objectID)) + nextID = object.inReplyToID + } + } + return nodes.reversed() + } + } + + class LeafNode { + let statusID: Mastodon.Entity.Status.ID + let objectID: NSManagedObjectID + let repliesCount: Int + let children: [LeafNode] + + var isChildrenExpanded: Bool = false // default collapsed + + init( + statusID: Mastodon.Entity.Status.ID, + objectID: NSManagedObjectID, + repliesCount: Int, + children: [ThreadViewModel.LeafNode] + ) { + self.statusID = statusID + self.objectID = objectID + self.repliesCount = repliesCount + self.children = children + } + + static func tree( + for statusID: Mastodon.Entity.Status.ID, + from statuses: [Mastodon.Entity.Status], + domain: String, + managedObjectContext: NSManagedObjectContext + ) -> [LeafNode] { + // make an cache collection + var objectDict: [Mastodon.Entity.Status.ID: Status] = [:] + + managedObjectContext.performAndWait { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id }) + request.fetchLimit = statuses.count + let objects = managedObjectContext.safeFetch(request) + + for object in objects { + objectDict[object.id] = object + } + } + + var tree: [LeafNode] = [] + let firstTierStatuses = statuses.filter { $0.inReplyToID == statusID } + for status in firstTierStatuses { + guard let node = node(of: status.id, objectDict: objectDict) else { continue } + tree.append(node) + } + + return tree + } + + static func node( + of statusID: Mastodon.Entity.Status.ID, + objectDict: [Mastodon.Entity.Status.ID: Status] + ) -> LeafNode? { + guard let object = objectDict[statusID] else { return nil } + let replies = (object.replyFrom ?? Set()).sorted( + by: { $0.repliesCount?.intValue ?? 0 < $1.repliesCount?.intValue ?? 0 } + ) + let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) } + return LeafNode( + statusID: statusID, + objectID: object.objectID, + repliesCount: object.repliesCount?.intValue ?? 0, + children: children + ) + } + } + +} + diff --git a/Mastodon/Service/APIService/APIService+Thread.swift b/Mastodon/Service/APIService/APIService+Thread.swift new file mode 100644 index 000000000..2633518ca --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Thread.swift @@ -0,0 +1,57 @@ +// +// APIService+Thread.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-12. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService { + + func statusContext( + domain: String, + statusID: Mastodon.Entity.Status.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + guard domain == mastodonAuthenticationBox.domain else { + return Fail(error: APIError.implicit(.badRequest)).eraseToAnyPublisher() + } + + return Mastodon.API.Statuses.statusContext( + session: session, + domain: domain, + statusID: statusID, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { $0.ancestors + $0.descendants }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + +} diff --git a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift index 9bc699b71..f5bb4ea3d 100644 --- a/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift +++ b/Mastodon/Service/APIService/Persist/APIService+Persist+Status.swift @@ -221,6 +221,13 @@ extension APIService.Persist { break } + // reply relationship link + for (_, status) in statusCache.dictionary { + guard let replyToID = status.inReplyToID, status.replyTo == nil else { continue } + guard let replyTo = statusCache.dictionary[replyToID] else { continue } + status.update(replyTo: replyTo) + } + // print working record tree map #if DEBUG DispatchQueue.global(qos: .utility).async { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index da54c9344..ae5d5e670 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -146,3 +146,46 @@ extension Mastodon.API.Statuses { } } + +extension Mastodon.API.Statuses { + + static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("statuses/\(statusID)/context") + } + + /// Parent and child statuses + /// + /// View statuses above and below this status in the thread. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/12 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id of status + /// - authorization: User token. Optional for public statuses + /// - Returns: `AnyPublisher` contains `Context` nested in the response + public static func statusContext( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: statusContextEndpointURL(domain: domain, statusID: statusID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Context.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From 773bfb6dd21e2a9fa9abb788923f410006a27e26 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 12 Apr 2021 16:31:53 +0800 Subject: [PATCH 230/400] feature: notification API and CoreData --- .../CoreData.xcdatamodel/contents | 18 ++- CoreDataStack/Entity/Notification.swift | 110 +++++++++++++++ Localization/app.json | 13 ++ Mastodon.xcodeproj/project.pbxproj | 40 ++++++ .../Diffiable/Item/NotificationItem.swift | 40 ++++++ .../Section/NotificationSection.swift | 75 +++++++++++ Mastodon/Generated/Strings.swift | 20 +++ .../Resources/en.lproj/Localizable.strings | 7 + .../NotificationViewController.swift | 126 +++++++++++++++++- ...otificationViewModel+LoadLatestState.swift | 96 +++++++++++++ .../NotificationViewModel+diffable.swift | 118 ++++++++++++++++ .../Notification/NotificationViewModel.swift | 87 ++++++++++++ .../NotificationTableViewCell.swift | 109 +++++++++++++++ .../APIService/APIService+Notification.swift | 65 +++++++++ .../API/Mastodon+API+Notifications.swift | 47 ++++--- .../Entity/Mastodon+Entity+Notification.swift | 1 + 16 files changed, 952 insertions(+), 20 deletions(-) create mode 100644 CoreDataStack/Entity/Notification.swift create mode 100644 Mastodon/Diffiable/Item/NotificationItem.swift create mode 100644 Mastodon/Diffiable/Section/NotificationSection.swift create mode 100644 Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift create mode 100644 Mastodon/Scene/Notification/NotificationViewModel+diffable.swift create mode 100644 Mastodon/Scene/Notification/NotificationViewModel.swift create mode 100644 Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift create mode 100644 Mastodon/Service/APIService/APIService+Notification.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5ed4021a7..2569da5e6 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -65,6 +65,21 @@ + + + + + + + + + + + + + + + @@ -208,6 +223,7 @@ + @@ -217,4 +233,4 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift new file mode 100644 index 000000000..f19f68988 --- /dev/null +++ b/CoreDataStack/Entity/Notification.swift @@ -0,0 +1,110 @@ +// +// MastodonNotification.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/13. +// + +import Foundation +import CoreData + +public final class MastodonNotification: NSManagedObject { + public typealias ID = UUID + @NSManaged public private(set) var identifier: ID + @NSManaged public private(set) var id: String + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var createAt: Date + @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var type: String + @NSManaged public private(set) var account: MastodonUser + @NSManaged public private(set) var status: Status? + +} + +extension MastodonNotification { + public override func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier)) + } + + public override func willSave() { + super.willSave() + setPrimitiveValue(Date(), forKey: #keyPath(MastodonNotification.updatedAt)) + } + +} + +public extension MastodonNotification { + @discardableResult + static func insert( + into context: NSManagedObjectContext, + domain: String, + property: Property + ) -> MastodonNotification { + let notification: MastodonNotification = context.insertObject() + notification.id = property.id + notification.createAt = property.createdAt + notification.updatedAt = property.createdAt + notification.type = property.type + notification.account = property.account + notification.status = property.status + notification.domain = domain + return notification + } +} + +public extension MastodonNotification { + struct Property { + public init(id: String, + type: String, + account: MastodonUser, + status: Status?, + createdAt: Date) { + self.id = id + self.type = type + self.account = account + self.status = status + self.createdAt = createdAt + } + + public let id: String + public let type: String + public let account: MastodonUser + public let status: Status? + public let createdAt: Date + } +} + +extension MastodonNotification { + static func predicate(domain: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain) + } + + static func predicate(type: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.type), type) + } + + public static func predicate(domain: String, type: String) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonNotification.predicate(domain: domain), + MastodonNotification.predicate(type: type) + ]) + } + + static func predicate(types: [String]) -> NSPredicate { + return NSPredicate(format: "%K IN %@", #keyPath(MastodonNotification.type), types) + } + + public static func predicate(domain: String, types: [String]) -> NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonNotification.predicate(domain: domain), + MastodonNotification.predicate(types: types) + ]) + } +} + +extension MastodonNotification: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \MastodonNotification.updatedAt, ascending: false)] + } +} diff --git a/Localization/app.json b/Localization/app.json index 120458f74..33f1abc2d 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -321,6 +321,19 @@ }, "favorite": { "title": "Your Favorites" + }, + "notification": { + "title": { + "Everything": "Everything", + "Mentions": "Mentions" + }, + "action": { + "follow": "followed you", + "favourite": "favorited your post", + "reblog": "rebloged your post", + "poll": "Your poll has ended", + "mention": "mentioned you" + } } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b52139898..b2258c1df 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3D33725E6401400AAD544 /* PickServerCell.swift */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; + 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */; }; + 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */; }; 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; @@ -49,6 +51,8 @@ 2D34D9D126148D9E0081BFC0 /* APIService+Recommend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */; }; 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9DA261494120081BFC0 /* APIService+Search.swift */; }; 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */; }; + 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35237926256D920031AF25 /* NotificationSection.swift */; }; + 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */; }; 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */; }; 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; }; 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; @@ -76,6 +80,9 @@ 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */; }; 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */; }; 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */; }; + 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D607AD726242FC500B70763 /* NotificationViewModel.swift */; }; + 2D6125472625436B00299647 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6125462625436B00299647 /* Notification.swift */; }; + 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61254C262547C200299647 /* APIService+Notification.swift */; }; 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */; }; 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D61335D25C1894B00CAE157 /* APIService.swift */; }; 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 2D61336825C18A4F00CAE157 /* AlamofireNetworkActivityIndicator */; }; @@ -91,6 +98,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; 2D7631B325C159F700929FB9 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631B225C159F700929FB9 /* Item.swift */; }; + 2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; }; 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */; }; 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; }; 2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; }; @@ -409,6 +417,8 @@ 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerSearchCell.swift; sourceTree = ""; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = ""; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; + 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+diffable.swift"; sourceTree = ""; }; + 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadLatestState.swift"; sourceTree = ""; }; 2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = ""; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = ""; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -428,6 +438,8 @@ 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Recommend.swift"; sourceTree = ""; }; 2D34D9DA261494120081BFC0 /* APIService+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Search.swift"; sourceTree = ""; }; 2D34D9E126149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendTagsCollectionViewCell.swift; sourceTree = ""; }; + 2D35237926256D920031AF25 /* NotificationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSection.swift; sourceTree = ""; }; + 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; 2D364F7125E66D7500204FDC /* MastodonResendEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewController.swift; sourceTree = ""; }; 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; @@ -453,6 +465,9 @@ 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D5A3D3725CF8D9F002347D6 /* ScrollViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewContainer.swift; sourceTree = ""; }; 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+DebugAction.swift"; sourceTree = ""; }; + 2D607AD726242FC500B70763 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; + 2D6125462625436B00299647 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; + 2D61254C262547C200299647 /* APIService+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Notification.swift"; sourceTree = ""; }; 2D61335725C188A000CAE157 /* APIService+Persist+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+Status.swift"; sourceTree = ""; }; 2D61335D25C1894B00CAE157 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error+Detail.swift"; sourceTree = ""; }; @@ -467,6 +482,7 @@ 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; 2D7631B225C159F700929FB9 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + 2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Tag.swift"; sourceTree = ""; }; 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = ""; }; 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = ""; }; @@ -878,6 +894,14 @@ path = CollectionViewCell; sourceTree = ""; }; + 2D35237F26256F470031AF25 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2D364F7025E66D5B00204FDC /* ResendEmail */ = { isa = PBXGroup; children = ( @@ -1032,6 +1056,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, @@ -1083,6 +1108,7 @@ children = ( 2D7631B225C159F700929FB9 /* Item.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D7867182625B77500211898 /* NotificationItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, @@ -1312,6 +1338,7 @@ DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, + 2D61254C262547C200299647 /* APIService+Notification.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, @@ -1487,6 +1514,7 @@ isa = PBXGroup; children = ( DB89BA2625C110B4008580ED /* Status.swift */, + 2D6125462625436B00299647 /* Notification.swift */, 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, @@ -1642,6 +1670,10 @@ isa = PBXGroup; children = ( DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */, + 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, + 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */, + 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */, + 2D35237F26256F470031AF25 /* TableViewCell */, ); path = Notification; sourceTree = ""; @@ -2210,6 +2242,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, + 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, @@ -2244,6 +2277,7 @@ DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, + 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, @@ -2263,6 +2297,7 @@ DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, + 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, @@ -2321,6 +2356,7 @@ DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */, DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, + 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, @@ -2329,6 +2365,7 @@ 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, + 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, @@ -2358,6 +2395,7 @@ 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, + 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, @@ -2370,6 +2408,7 @@ DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, + 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */, @@ -2472,6 +2511,7 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, + 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, DB45FAED25CA7A9A005A8AC7 /* MastodonAuthentication.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift new file mode 100644 index 000000000..e4a53d2b1 --- /dev/null +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -0,0 +1,40 @@ +// +// NotificationItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Foundation +import CoreData + +enum NotificationItem { + + case notification(ObjectID: NSManagedObjectID) + + case bottomLoader +} + +extension NotificationItem: Equatable { + static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { + switch (lhs, rhs) { + case (.bottomLoader, .bottomLoader): + return true + case (.notification(let idLeft),.notification(let idRight)): + return idLeft == idRight + default: + return false + } + } +} + +extension NotificationItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .notification(let id): + hasher.combine(id) + case .bottomLoader: + hasher.combine(String(describing: NotificationItem.bottomLoader.self)) + } + } +} diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift new file mode 100644 index 000000000..d697d3cef --- /dev/null +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -0,0 +1,75 @@ +// +// NotificationSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import Combine + +enum NotificationSection: Equatable, Hashable { + case main +} + +extension NotificationSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + timestampUpdatePublisher: AnyPublisher, + managedObjectContext: NSManagedObjectContext + ) -> UITableViewDiffableDataSource { + + return UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, notificationItem) -> UITableViewCell? in + switch notificationItem { + case .notification(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + let notification = managedObjectContext.object(with: objectID) as! MastodonNotification + let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type) + + var actionText: String + var actionImageName: String + switch type { + case .follow: + actionText = L10n.Scene.Notification.Action.follow + actionImageName = "person.crop.circle.badge.checkmark" + case .favourite: + actionText = L10n.Scene.Notification.Action.favourite + actionImageName = "star.fill" + case .reblog: + actionText = L10n.Scene.Notification.Action.reblog + actionImageName = "arrow.2.squarepath" + case .mention: + actionText = L10n.Scene.Notification.Action.mention + actionImageName = "at" + case .poll: + actionText = L10n.Scene.Notification.Action.poll + actionImageName = "list.bullet" + default: + actionText = "" + actionImageName = "" + } + + timestampUpdatePublisher + .sink { _ in + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionLabel.text = actionText + " · " + timeText + } + .store(in: &cell.disposeBag) + cell.nameLabel.text = notification.account.displayName + + if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { + cell.actionImageView.image = actionImage + } + return cell + case .bottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + cell.startAnimating() + return cell + } + } + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 14b993881..a94afa130 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -339,6 +339,26 @@ internal enum L10n { internal static let publishing = L10n.tr("Localizable", "Scene.HomeTimeline.NavigationBarState.Publishing") } } + internal enum Notification { + internal enum Action { + /// favorited your toot + internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite") + /// followed you + internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow") + /// mentioned you + internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention") + /// Your poll has ended + internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll") + /// boosted your toot + internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog") + } + internal enum Title { + /// Everything + internal static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything") + /// Mentions + internal static let mentions = L10n.tr("Localizable", "Scene.Notification.Title.Mentions") + } + } internal enum Profile { /// %@ posts internal static func subtitle(_ p1: Any) -> String { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 40000befa..e4b10c0cf 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -114,6 +114,13 @@ tap the link to confirm your account."; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; +"Scene.Notification.Action.Favourite" = "favorited your toot"; +"Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.Mention" = "mentioned you"; +"Scene.Notification.Action.Poll" = "Your poll has ended"; +"Scene.Notification.Action.Reblog" = "boosted your toot"; +"Scene.Notification.Title.Everything" = "Everything"; +"Scene.Notification.Title.Mentions" = "Mentions"; "Scene.Profile.Dashboard.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index f8b3ba815..51a94e89e 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -2,23 +2,147 @@ // NotificationViewController.swift // Mastodon // -// Created by MainasuK Cirno on 2021-2-23. +// Created by sxiaojian on 2021/4/12. // import UIKit +import Combine +import OSLog final class NotificationViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + private(set) lazy var viewModel = NotificationViewModel(context: context, coordinator: coordinator) + + let segmentControl: UISegmentedControl = { + let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything,L10n.Scene.Notification.Title.mentions]) + control.selectedSegmentIndex = 0 + control.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .touchUpInside) + return control + }() + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + return tableView + }() + + let refreshControl = UIRefreshControl() + } extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.searchResult.color + navigationItem.titleView = segmentControl + view.addSubview(tableView) + tableView.constrain([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(NotificationViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + tableView.delegate = self + viewModel.tableView = tableView + viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self + viewModel.setupDiffableDataSource(for: tableView) + + // bind refresh control + viewModel.isFetchingLatestNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] isFetching in + guard let self = self else { return } + if !isFetching { + UIView.animate(withDuration: 0.5) { [weak self] in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + } + } + .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // needs trigger manually after onboarding dismiss + setNeedsStatusBarAppearanceUpdate() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).count == 0 { + self.viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate { _ in + // do nothing + } completion: { _ in + self.tableView.reloadData() + } + } + } + +extension NotificationViewController { + @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) { + os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) + } + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else { + sender.endRefreshing() + return + } + } +} + +// MARK: - UITableViewDelegate +extension NotificationViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return 68 + } +} + +// MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate +extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { + func navigationBar() -> UINavigationBar? { + return navigationController?.navigationBar + } +} + +//// MARK: - UIScrollViewDelegate +//extension NotificationViewController { +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// handleScrollViewDidScroll(scrollView) +// } +//} +// +//extension NotificationViewController: LoadMoreConfigurableTableViewContainer { +// typealias BottomLoaderTableViewCell = SearchBottomLoader +// typealias LoadingState = NotificationViewController.LoadOldestState.Loading +// var loadMoreConfigurableTableView: UITableView { return tableView } +// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +//} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift new file mode 100644 index 000000000..364085c89 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -0,0 +1,96 @@ +// +// NotificationViewModel+LoadLatestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import os.log +import func QuartzCore.CACurrentMediaTime +import Foundation +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK + +extension NotificationViewModel { + class LoadLatestState: GKState { + weak var viewModel: NotificationViewModel? + + init(viewModel: NotificationViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadLatestStateMachinePublisher.send(self) + } + } +} + +extension NotificationViewModel.LoadLatestState { + class Initial: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class Loading: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + // sign out when loading will enter here + stateMachine.enter(Fail.self) + return + } + let query = Mastodon.API.Notifications.Query( + maxID: nil, + sinceID: nil, + minID: nil, + limit: nil, + excludeTypes: Mastodon.API.Notifications.allExcludeTypes(), + accountID: nil) + viewModel.context.apiService.allNotifications( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) + + + } + } + + class Fail: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: NotificationViewModel.LoadLatestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift new file mode 100644 index 000000000..c68096c86 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -0,0 +1,118 @@ +// +// NotificationViewModel+diffable.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +extension NotificationViewModel { + + func setupDiffableDataSource( + for tableView: UITableView + ) { + let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = NotificationSection.tableViewDiffableDataSource( + for: tableView, + timestampUpdatePublisher: timestampUpdatePublisher, + managedObjectContext: context.managedObjectContext + ) + } + +} + +extension NotificationViewModel: NSFetchedResultsControllerDelegate { + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + guard let tableView = self.tableView else { return } + guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + + guard let diffableDataSource = self.diffableDataSource else { return } + let oldSnapshot = diffableDataSource.snapshot() + + let predicate = fetchedResultsController.fetchRequest.predicate + let parentManagedObjectContext = fetchedResultsController.managedObjectContext + let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.parent = parentManagedObjectContext + + managedObjectContext.perform { + + let notifications: [MastodonNotification] = { + let request = MastodonNotification.sortedFetchRequest + request.returnsObjectsAsFaults = false + request.predicate = predicate + do { + return try managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + newSnapshot.appendItems(notifications.map({NotificationItem.notification(ObjectID: $0.objectID)}), toSection: .main) + newSnapshot.appendItems([.bottomLoader], toSection: .main) + + DispatchQueue.main.async { + guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { + diffableDataSource.apply(newSnapshot) + self.isFetchingLatestNotification.value = false + return + } + + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.scrollToRow(at: difference.targetIndexPath, at: .top, animated: false) + tableView.contentOffset.y = tableView.contentOffset.y - difference.offset + self.isFetchingLatestNotification.value = false + } + } + } + } + + private struct Difference { + let item: T + let sourceIndexPath: IndexPath + let targetIndexPath: IndexPath + let offset: CGFloat + } + + private func calculateReloadSnapshotDifference( + navigationBar: UINavigationBar, + tableView: UITableView, + oldSnapshot: NSDiffableDataSourceSnapshot, + newSnapshot: NSDiffableDataSourceSnapshot + ) -> Difference? { + guard oldSnapshot.numberOfItems != 0 else { return nil } + + // old snapshot not empty. set source index path to first item if not match + let sourceIndexPath = UIViewController.topVisibleTableViewCellIndexPath(in: tableView, navigationBar: navigationBar) ?? IndexPath(row: 0, section: 0) + + guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + + let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] + guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } + let targetIndexPath = IndexPath(row: itemIndex, section: 0) + + let offset = UIViewController.tableViewCellOriginOffsetToWindowTop(in: tableView, at: sourceIndexPath, navigationBar: navigationBar) + return Difference( + item: timelineItem, + sourceIndexPath: sourceIndexPath, + targetIndexPath: targetIndexPath, + offset: offset + ) + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift new file mode 100644 index 000000000..4736785f6 --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -0,0 +1,87 @@ +// +// NotificationViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/12. +// + +import Foundation +import Combine +import UIKit +import CoreData +import CoreDataStack +import GameplayKit + +final class NotificationViewModel: NSObject { + + var disposeBag = Set() + + // input + let context: AppContext + weak var coordinator: SceneCoordinator! + weak var tableView: UITableView! + weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + + + let activeMastodonAuthenticationBox: CurrentValueSubject + let fetchedResultsController: NSFetchedResultsController! + let notificationPredicate = CurrentValueSubject(nil) + let cellFrameCache = NSCache() + + let isFetchingLatestNotification = CurrentValueSubject(false) + + //output + var diffableDataSource: UITableViewDiffableDataSource! + // top loader + private(set) lazy var loadLatestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadLatestState.Initial(viewModel: self), + LoadLatestState.Loading(viewModel: self), + LoadLatestState.Fail(viewModel: self), + LoadLatestState.Idle(viewModel: self), + ]) + stateMachine.enter(LoadLatestState.Initial.self) + return stateMachine + }() + + lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + + init(context: AppContext,coordinator: SceneCoordinator) { + self.coordinator = coordinator + self.context = context + self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) + self.fetchedResultsController = { + let fetchRequest = MastodonNotification.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status),#keyPath(MastodonNotification.account)] + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context.managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + + super.init() + self.fetchedResultsController.delegate = self + context.authenticationService.activeMastodonAuthenticationBox + .assign(to: \.value, on: activeMastodonAuthenticationBox) + .store(in: &disposeBag) + + notificationPredicate + .compactMap{ $0 } + .sink { [weak self] predicate in + guard let self = self else { return } + self.fetchedResultsController.fetchRequest.predicate = predicate + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift new file mode 100644 index 000000000..8a1b35721 --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -0,0 +1,109 @@ +// +// NotificationTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Foundation +import UIKit +import Combine + + +final class NotificationTableViewCell: UITableViewCell { + + static let actionImageBorderWidth: CGFloat = 3 + + var disposeBag = Set() + + let avatatImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + return imageView + }() + + let actionImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + imageView.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth + imageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + imageView.tintColor = Asset.Colors.Background.searchResult.color + return imageView + }() + + let actionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFont.preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + var nameLabelTop: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + avatatImageView.af.cancelImageRequest() + + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension NotificationTableViewCell { + + func configure() { + contentView.addSubview(avatatImageView) + avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) + avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) + + contentView.addSubview(actionImageView) + actionImageView.pin(toSize: CGSize(width: 24, height: 24)) + actionImageView.pin(top: 33, left: 33, bottom: nil, right: nil) + + nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor) + nameLabel.constrain([ + nameLabelTop, + nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) + ]) + + actionLabel.constrain([ + actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), + actionLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor), + contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4) + ]) + } + + public func nameLabelLayoutIn(center: Bool) { + if center { + nameLabelTop.constant = 24 + } else { + nameLabelTop.constant = 12 + } + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + self.actionImageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + } +} diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift new file mode 100644 index 000000000..745a04fa0 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -0,0 +1,65 @@ +// +// APIService+Notification.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/13. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import OSLog + +extension APIService { + func allNotifications( + domain: String, + query: Mastodon.API.Notifications.Query, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + return Mastodon.API.Notifications.getNotifications( + session: session, + domain: domain, + query: query, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { notification in + let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + var status: Status? + if let statusEntity = notification.status { + let (statusInCoreData,_,_) = APIService.CoreData.createOrMergeStatus( + into: self.backgroundManagedObjectContext, + for: nil, + domain: domain, + entity: statusEntity, + statusCache: nil, + userCache: nil, + networkDate: Date(), + log: log) + status = statusInCoreData + } + // use constrain to avoid repeated save + _ = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: Date())) + + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index cdee82926..b7fd0fb46 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -1,18 +1,19 @@ // // File.swift -// +// // // Created by BradGao on 2021/4/1. // -import Foundation import Combine +import Foundation -extension Mastodon.API.Notifications { - static func notificationsEndpointURL(domain: String) -> URL { - Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("notifications") +public extension Mastodon.API.Notifications { + internal static func notificationsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") } - static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { + + internal static func getNotificationEndpointURL(domain: String, notificationID: String) -> URL { notificationsEndpointURL(domain: domain).appendingPathComponent(notificationID) } @@ -27,15 +28,15 @@ extension Mastodon.API.Notifications { /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" - /// - query: `GetAllNotificationsQuery` with query parameters + /// - query: `NotificationsQuery` with query parameters /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func getAll( + static func getNotifications( session: URLSession, domain: String, - query: GetAllNotificationsQuery, - authorization: Mastodon.API.OAuth.Authorization? - ) -> AnyPublisher, Error> { + query: Mastodon.API.Notifications.Query, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: notificationsEndpointURL(domain: domain), query: query, @@ -63,12 +64,12 @@ extension Mastodon.API.Notifications { /// - notificationID: ID of the notification. /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - public static func get( + static func getNotification( session: URLSession, domain: String, notificationID: String, - authorization: Mastodon.API.OAuth.Authorization? - ) -> AnyPublisher, Error> { + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: getNotificationEndpointURL(domain: domain, notificationID: notificationID), query: nil, @@ -82,12 +83,22 @@ extension Mastodon.API.Notifications { .eraseToAnyPublisher() } - public struct GetAllNotificationsQuery: Codable, PagedQueryType, GetQuery { + static func allExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { + [.follow] + } + + static func mentionsExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { + [.follow, .followRequest, .favourite, .reblog, .poll] + } +} + +public extension Mastodon.API.Notifications { + struct Query: Codable, PagedQueryType, GetQuery { public let maxID: Mastodon.Entity.Status.ID? public let sinceID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? public let limit: Int? - public let excludeTypes: [String]? + public let excludeTypes: [Mastodon.Entity.Notification.NotificationType]? public let accountID: String? public init( @@ -95,7 +106,7 @@ extension Mastodon.API.Notifications { sinceID: Mastodon.Entity.Status.ID? = nil, minID: Mastodon.Entity.Status.ID? = nil, limit: Int? = nil, - excludeTypes: [String]? = nil, + excludeTypes: [Mastodon.Entity.Notification.NotificationType]? = nil, accountID: String? = nil ) { self.maxID = maxID @@ -114,7 +125,7 @@ extension Mastodon.API.Notifications { limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } if let excludeTypes = excludeTypes { excludeTypes.forEach { - items.append(URLQueryItem(name: "exclude_types[]", value: $0)) + items.append(URLQueryItem(name: "exclude_types[]", value: $0.rawValue)) } } accountID.flatMap { items.append(URLQueryItem(name: "account_id", value: $0)) } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift index 413c89bd3..0cdcc2e7c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Notification.swift @@ -37,6 +37,7 @@ extension Mastodon.Entity { } extension Mastodon.Entity.Notification { + public typealias NotificationType = Type public enum `Type`: RawRepresentable, Codable { case follow case followRequest From 42628398e6891c34d2821262b1abd17c436c3140 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 13 Apr 2021 21:31:49 +0800 Subject: [PATCH 231/400] chore: display Notification Cell --- CoreDataStack/Entity/Notification.swift | 14 +------ .../Section/NotificationSection.swift | 20 ++++++++++ Mastodon/Generated/Assets.swift | 5 +++ Mastodon/Generated/Strings.swift | 4 +- .../Colors/Notification/Contents.json | 9 +++++ .../favourite.colorset/Contents.json | 20 ++++++++++ .../mention.colorset/Contents.json | 38 +++++++++++++++++++ .../reblog.colorset/Contents.json | 38 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 4 +- .../NotificationViewController.swift | 11 ++++-- ...otificationViewModel+LoadLatestState.swift | 2 +- .../NotificationViewModel+diffable.swift | 4 +- .../Notification/NotificationViewModel.swift | 21 +++++++++- .../NotificationTableViewCell.swift | 31 ++++++++++----- .../APIService/APIService+Notification.swift | 7 ++-- .../API/Mastodon+API+Notifications.swift | 2 +- 16 files changed, 192 insertions(+), 38 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift index f19f68988..144ad9c23 100644 --- a/CoreDataStack/Entity/Notification.swift +++ b/CoreDataStack/Entity/Notification.swift @@ -76,7 +76,7 @@ public extension MastodonNotification { } extension MastodonNotification { - static func predicate(domain: String) -> NSPredicate { + public static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain) } @@ -90,17 +90,7 @@ extension MastodonNotification { MastodonNotification.predicate(type: type) ]) } - - static func predicate(types: [String]) -> NSPredicate { - return NSPredicate(format: "%K IN %@", #keyPath(MastodonNotification.type), types) - } - - public static func predicate(domain: String, types: [String]) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonNotification.predicate(domain: domain), - MastodonNotification.predicate(types: types) - ]) - } + } extension MastodonNotification: Managed { diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index d697d3cef..277a40f50 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -32,25 +32,32 @@ extension NotificationSection { var actionText: String var actionImageName: String + var color: UIColor switch type { case .follow: actionText = L10n.Scene.Notification.Action.follow actionImageName = "person.crop.circle.badge.checkmark" + color = Asset.Colors.brandBlue.color case .favourite: actionText = L10n.Scene.Notification.Action.favourite actionImageName = "star.fill" + color = Asset.Colors.Notification.favourite.color case .reblog: actionText = L10n.Scene.Notification.Action.reblog actionImageName = "arrow.2.squarepath" + color = Asset.Colors.Notification.reblog.color case .mention: actionText = L10n.Scene.Notification.Action.mention actionImageName = "at" + color = Asset.Colors.Notification.mention.color case .poll: actionText = L10n.Scene.Notification.Action.poll actionImageName = "list.bullet" + color = Asset.Colors.brandBlue.color default: actionText = "" actionImageName = "" + color = .clear } timestampUpdatePublisher @@ -59,11 +66,24 @@ extension NotificationSection { cell.actionLabel.text = actionText + " · " + timeText } .store(in: &cell.disposeBag) + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionImageBackground.backgroundColor = color + cell.actionLabel.text = actionText + " · " + timeText cell.nameLabel.text = notification.account.displayName + cell.avatatImageView.af.setImage( + withURL: URL(string: notification.account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } + if let _ = notification.status { + cell.nameLabelLayoutIn(center: true) + } else { + cell.nameLabelLayoutIn(center: false) + } return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 843fce02c..ef7ae9292 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -70,6 +70,11 @@ internal enum Asset { internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } + internal enum Notification { + internal static let favourite = ColorAsset(name: "Colors/Notification/favourite") + internal static let mention = ColorAsset(name: "Colors/Notification/mention") + internal static let reblog = ColorAsset(name: "Colors/Notification/reblog") + } internal enum Shadow { internal static let searchCard = ColorAsset(name: "Colors/Shadow/SearchCard") } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a94afa130..21a5053b8 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -341,7 +341,7 @@ internal enum L10n { } internal enum Notification { internal enum Action { - /// favorited your toot + /// favorited your post internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite") /// followed you internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow") @@ -349,7 +349,7 @@ internal enum L10n { internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention") /// Your poll has ended internal static let poll = L10n.tr("Localizable", "Scene.Notification.Action.Poll") - /// boosted your toot + /// rebloged your post internal static let reblog = L10n.tr("Localizable", "Scene.Notification.Action.Reblog") } internal enum Title { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json new file mode 100644 index 000000000..36de20274 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/favourite.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0", + "green" : "204", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json new file mode 100644 index 000000000..9dff2f59b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/mention.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "222", + "green" : "82", + "red" : "175" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "242", + "green" : "90", + "red" : "191" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json new file mode 100644 index 000000000..ec427ccaa --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Notification/reblog.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "89", + "green" : "199", + "red" : "52" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "75", + "green" : "215", + "red" : "20" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e4b10c0cf..aa43ec64d 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -114,11 +114,11 @@ tap the link to confirm your account."; "Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; "Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; "Scene.HomeTimeline.Title" = "Home"; -"Scene.Notification.Action.Favourite" = "favorited your toot"; +"Scene.Notification.Action.Favourite" = "favorited your post"; "Scene.Notification.Action.Follow" = "followed you"; "Scene.Notification.Action.Mention" = "mentioned you"; "Scene.Notification.Action.Poll" = "Your poll has ended"; -"Scene.Notification.Action.Reblog" = "boosted your toot"; +"Scene.Notification.Action.Reblog" = "rebloged your post"; "Scene.Notification.Title.Everything" = "Everything"; "Scene.Notification.Title.Mentions" = "Mentions"; "Scene.Profile.Dashboard.Followers" = "followers"; diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 51a94e89e..b87172925 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -27,9 +27,10 @@ final class NotificationViewController: UIViewController, NeedsDependency { let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .none - tableView.backgroundColor = .clear + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + tableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) return tableView }() @@ -58,7 +59,7 @@ extension NotificationViewController { viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self viewModel.setupDiffableDataSource(for: tableView) - + viewModel.viewDidLoad.send() // bind refresh control viewModel.isFetchingLatestNotification .receive(on: DispatchQueue.main) @@ -124,6 +125,10 @@ extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return 68 } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 68 + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 364085c89..3e88de9a7 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -43,7 +43,7 @@ extension NotificationViewModel.LoadLatestState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { // sign out when loading will enter here stateMachine.enter(Fail.self) return diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index c68096c86..1d77d41b4 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -65,7 +65,9 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) newSnapshot.appendItems(notifications.map({NotificationItem.notification(ObjectID: $0.objectID)}), toSection: .main) - newSnapshot.appendItems([.bottomLoader], toSection: .main) + if !notifications.isEmpty { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } DispatchQueue.main.async { guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 4736785f6..1b4890317 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -11,6 +11,7 @@ import UIKit import CoreData import CoreDataStack import GameplayKit +import MastodonSDK final class NotificationViewModel: NSObject { @@ -19,9 +20,10 @@ final class NotificationViewModel: NSObject { // input let context: AppContext weak var coordinator: SceneCoordinator! - weak var tableView: UITableView! + weak var tableView: UITableView? weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? + let viewDidLoad = PassthroughSubject() let activeMastodonAuthenticationBox: CurrentValueSubject let fetchedResultsController: NSFetchedResultsController! @@ -68,7 +70,13 @@ final class NotificationViewModel: NSObject { super.init() self.fetchedResultsController.delegate = self context.authenticationService.activeMastodonAuthenticationBox - .assign(to: \.value, on: activeMastodonAuthenticationBox) + .sink(receiveValue: { [weak self] box in + guard let self = self else { return } + self.activeMastodonAuthenticationBox.value = box + if let domain = box?.domain { + self.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + } + }) .store(in: &disposeBag) notificationPredicate @@ -83,5 +91,14 @@ final class NotificationViewModel: NSObject { } } .store(in: &disposeBag) + + self.viewDidLoad + .sink { [weak self] in + + guard let domain = self?.activeMastodonAuthenticationBox.value?.domain else { return } + self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 8a1b35721..b252b76d8 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -12,7 +12,7 @@ import Combine final class NotificationTableViewCell: UITableViewCell { - static let actionImageBorderWidth: CGFloat = 3 + static let actionImageBorderWidth: CGFloat = 2 var disposeBag = Set() @@ -26,15 +26,21 @@ final class NotificationTableViewCell: UITableViewCell { let actionImageView: UIImageView = { let imageView = UIImageView() - imageView.layer.cornerRadius = 4 - imageView.layer.cornerCurve = .continuous - imageView.clipsToBounds = true - imageView.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth - imageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor imageView.tintColor = Asset.Colors.Background.searchResult.color return imageView }() + let actionImageBackground: UIView = { + let view = UIView() + view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth)/2 + view.layer.cornerCurve = .continuous + view.clipsToBounds = true + view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth + view.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + view.tintColor = Asset.Colors.Background.searchResult.color + return view + }() + let actionLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.secondary.color @@ -77,16 +83,21 @@ extension NotificationTableViewCell { avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) - contentView.addSubview(actionImageView) - actionImageView.pin(toSize: CGSize(width: 24, height: 24)) - actionImageView.pin(top: 33, left: 33, bottom: nil, right: nil) + contentView.addSubview(actionImageBackground) + actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) + actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) + + actionImageBackground.addSubview(actionImageView) + actionImageView.constrainToCenter() nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor) + contentView.addSubview(nameLabel) nameLabel.constrain([ nameLabelTop, nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) ]) + contentView.addSubview(actionLabel) actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor), @@ -104,6 +115,6 @@ extension NotificationTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - self.actionImageView.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + self.actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index 745a04fa0..84cb6d3c0 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -28,9 +28,7 @@ extension APIService { let log = OSLog.api return self.backgroundManagedObjectContext.performChanges { response.value.forEach { notification in - let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) - let flag = isCreated ? "+" : "-" - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + let (mastodonUser,_) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) var status: Status? if let statusEntity = notification.status { let (statusInCoreData,_,_) = APIService.CoreData.createOrMergeStatus( @@ -45,7 +43,8 @@ extension APIService { status = statusInCoreData } // use constrain to avoid repeated save - _ = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: Date())) + let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: Date())) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.type, notification.account.username) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index b7fd0fb46..1cc54add5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -84,7 +84,7 @@ public extension Mastodon.API.Notifications { } static func allExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { - [.follow] + [.followRequest] } static func mentionsExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { From 8df3b7d4643be04a2f6fde73e6eb29e1805cfedd Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 11:22:54 +0800 Subject: [PATCH 232/400] feat: make new reply display first --- Mastodon/Scene/Thread/ThreadViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 2f0a9daca..50df678c6 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -263,7 +263,7 @@ extension ThreadViewModel { ) -> LeafNode? { guard let object = objectDict[statusID] else { return nil } let replies = (object.replyFrom ?? Set()).sorted( - by: { $0.repliesCount?.intValue ?? 0 < $1.repliesCount?.intValue ?? 0 } + by: { $0.createdAt > $1.createdAt } // order by date ) let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) } return LeafNode( From 0eff43e1d1f540b66afd410737e582a7c082b751 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 15:24:54 +0800 Subject: [PATCH 233/400] feat: update compose scene UI appearance --- Localization/app.json | 6 ++- .../xcschemes/xcschememanagement.plist | 2 +- .../Section/ComposeStatusSection.swift | 16 ++++-- .../Diffiable/Section/StatusSection.swift | 2 +- Mastodon/Generated/Assets.swift | 42 ++++++++------- Mastodon/Generated/Strings.swift | 8 +++ .../Banner => Scene/Compose}/Contents.json | 0 .../Compose/background.colorset/Contents.json | 38 +++++++++++++ .../toolbar.background.colorset/Contents.json | 38 +++++++++++++ .../{Profile => Scene}/Contents.json | 0 .../Profile/Banner}/Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../username.gray.colorset/Contents.json | 0 .../Profile}/Contents.json | 0 .../Scene/Welcome/Contents.json | 9 ++++ .../Scene/Welcome/illustration/Contents.json | 9 ++++ .../background.cyan.colorset/Contents.json | 0 .../cloud.base.imageset/Contents.json | 0 .../untitled10007Group61.png | Bin .../untitled10007Group61@2x.png | Bin .../untitled10007Group61@3x.png | Bin .../Contents.json | 0 .../untitled10006Group21.png | Bin .../untitled10006Group21@2x.png | Bin .../untitled10006Group21@3x.png | Bin .../Contents.json | 0 .../untitled10003Group11.png | Bin .../untitled10003Group11@2x.png | Bin .../untitled10003Group11@3x.png | Bin .../Contents.json | 0 .../untitled10005Group101.png | Bin .../untitled10005Group101@2x.png | Bin .../untitled10005Group101@3x.png | Bin .../Contents.json | 0 .../untitled10004Group111.png | Bin .../untitled10004Group111@2x.png | Bin .../untitled10004Group111@3x.png | Bin .../Contents.json | 0 .../mastodon.logo.black.pdf | 0 .../Contents.json | 0 .../mastodon.logo.black.large.pdf | 0 .../mastodon.logo.imageset/Contents.json | 0 .../mastodon.logo.imageset/logotypeFull1.pdf | 0 .../Contents.json | 0 .../logotypeFull1.large.pdf | Bin .../Resources/en.lproj/Localizable.strings | 2 + ...iedToStatusContentCollectionViewCell.swift | 25 +++++++++ .../Scene/Compose/ComposeViewController.swift | 46 +++++++++------- Mastodon/Scene/Compose/ComposeViewModel.swift | 1 + .../Compose/View/ComposeToolbarView.swift | 50 +++++++++++++++--- .../View/WelcomeIllustrationView.swift | 12 ++--- .../Welcome/WelcomeViewController.swift | 2 +- .../Header/View/ProfileHeaderView.swift | 8 +-- .../Scene/Share/View/Content/StatusView.swift | 4 +- 55 files changed, 257 insertions(+), 63 deletions(-) rename Mastodon/Resources/Assets.xcassets/{Profile/Banner => Scene/Compose}/Contents.json (100%) create mode 100644 Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/{Profile => Scene}/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{Welcome => Scene/Profile/Banner}/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Profile/Banner/bio.edit.background.gray.colorset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Profile/Banner/name.edit.background.gray.colorset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Profile/Banner/username.gray.colorset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{Welcome/illustration => Scene/Profile}/Contents.json (100%) create mode 100644 Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/background.cyan.colorset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/cloud.base.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.black.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.black.large.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.imageset/logotypeFull1.pdf (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.large.imageset/Contents.json (100%) rename Mastodon/Resources/Assets.xcassets/{ => Scene}/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf (100%) diff --git a/Localization/app.json b/Localization/app.json index 08a70feb3..5d8ad2645 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -240,6 +240,7 @@ }, "content_input_placeholder": "Type or paste what's on your mind", "compose_action": "Publish", + "replying_to_user": "replying to %s", "attachment": { "photo": "photo", "video": "video", @@ -254,7 +255,8 @@ "six_hours": "6 Hours", "one_day": "1 Day", "three_days": "3 Days", - "seven_days": "7 Days" + "seven_days": "7 Days", + "option_number": "Option %ld" }, "content_warning": { "placeholder": "Write an accurate warning here..." @@ -336,4 +338,4 @@ } } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..fd1ce69a1 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 20 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 56aa32798..c8a8bc180 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -50,8 +50,15 @@ extension ComposeStatusSection { weak composeStatusPollExpiresOptionCollectionViewCellDelegate ] collectionView, indexPath, item -> UICollectionViewCell? in switch item { - case .replyTo(let repliedToStatusObjectID): + case .replyTo(let replyToStatusObjectID): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentCollectionViewCell + managedObjectContext.perform { + guard let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { + return + } + let status = replyTo.reblog ?? replyTo + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + } return cell case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell @@ -63,9 +70,10 @@ extension ComposeStatusSection { return } cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerInfoLabel.text = "[TODO] \(replyTo.author.displayName)" + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) } - ComposeStatusSection.configure(cell: cell, attribute: attribute) + ComposeStatusSection.configureStatusContent(cell: cell, attribute: attribute) cell.textEditorView.textAttributesDelegate = textEditorViewTextAttributesDelegate cell.composeContent .removeDuplicates() @@ -196,7 +204,7 @@ extension ComposeStatusSection { extension ComposeStatusSection { - static func configure( + static func configureStatusContent( cell: ComposeStatusContentCollectionViewCell, attribute: ComposeStatusItem.ComposeStatusAttribute ) { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index d0f93921a..36d4853a8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -393,7 +393,7 @@ extension StatusSection { ) { if status.reblog != nil { cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) cell.statusView.headerInfoLabel.text = { let author = status.author let name = author.displayName.isEmpty ? author.username : author.displayName diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 843fce02c..52efbda77 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -91,26 +91,32 @@ internal enum Asset { internal enum Connectivity { internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") } - internal enum Profile { - internal enum Banner { - internal static let bioEditBackgroundGray = ColorAsset(name: "Profile/Banner/bio.edit.background.gray") - internal static let nameEditBackgroundGray = ColorAsset(name: "Profile/Banner/name.edit.background.gray") - internal static let usernameGray = ColorAsset(name: "Profile/Banner/username.gray") + internal enum Scene { + internal enum Compose { + internal static let background = ColorAsset(name: "Scene/Compose/background") + internal static let toolbarBackground = ColorAsset(name: "Scene/Compose/toolbar.background") } - } - internal enum Welcome { - internal enum Illustration { - internal static let backgroundCyan = ColorAsset(name: "Welcome/illustration/background.cyan") - internal static let cloudBase = ImageAsset(name: "Welcome/illustration/cloud.base") - internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Welcome/illustration/elephant.on.airplane.with.contrail") - internal static let elephantThreeOnGrass = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass") - internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass.with.tree.three") - internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Welcome/illustration/elephant.three.on.grass.with.tree.two") + internal enum Profile { + internal enum Banner { + internal static let bioEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/bio.edit.background.gray") + internal static let nameEditBackgroundGray = ColorAsset(name: "Scene/Profile/Banner/name.edit.background.gray") + internal static let usernameGray = ColorAsset(name: "Scene/Profile/Banner/username.gray") + } + } + internal enum Welcome { + internal enum Illustration { + internal static let backgroundCyan = ColorAsset(name: "Scene/Welcome/illustration/background.cyan") + internal static let cloudBase = ImageAsset(name: "Scene/Welcome/illustration/cloud.base") + internal static let elephantOnAirplaneWithContrail = ImageAsset(name: "Scene/Welcome/illustration/elephant.on.airplane.with.contrail") + internal static let elephantThreeOnGrass = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass") + internal static let elephantThreeOnGrassWithTreeThree = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three") + internal static let elephantThreeOnGrassWithTreeTwo = ImageAsset(name: "Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two") + } + internal static let mastodonLogoBlack = ImageAsset(name: "Scene/Welcome/mastodon.logo.black") + internal static let mastodonLogoBlackLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.black.large") + internal static let mastodonLogo = ImageAsset(name: "Scene/Welcome/mastodon.logo") + internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") } - internal static let mastodonLogoBlack = ImageAsset(name: "Welcome/mastodon.logo.black") - internal static let mastodonLogoBlackLarge = ImageAsset(name: "Welcome/mastodon.logo.black.large") - internal static let mastodonLogo = ImageAsset(name: "Welcome/mastodon.logo") - internal static let mastodonLogoLarge = ImageAsset(name: "Welcome/mastodon.logo.large") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 685b47e4e..6eed41a29 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -224,6 +224,10 @@ internal enum L10n { internal static let composeAction = L10n.tr("Localizable", "Scene.Compose.ComposeAction") /// Type or paste what's on your mind internal static let contentInputPlaceholder = L10n.tr("Localizable", "Scene.Compose.ContentInputPlaceholder") + /// replying to %@ + internal static func replyingToUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1)) + } internal enum Attachment { /// This %@ is broken and can't be\nuploaded to Mastodon. internal static func attachmentBroken(_ p1: Any) -> String { @@ -259,6 +263,10 @@ internal enum L10n { internal static let oneDay = L10n.tr("Localizable", "Scene.Compose.Poll.OneDay") /// 1 Hour internal static let oneHour = L10n.tr("Localizable", "Scene.Compose.Poll.OneHour") + /// Option %ld + internal static func optionNumber(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Poll.OptionNumber", p1) + } /// 7 Days internal static let sevenDays = L10n.tr("Localizable", "Scene.Compose.Poll.SevenDays") /// 6 Hours diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Compose/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json new file mode 100644 index 000000000..e13fb4690 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1E", + "green" : "0x1C", + "red" : "0x1C" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json new file mode 100644 index 000000000..4ef70f635 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/toolbar.background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "222", + "green" : "216", + "red" : "214" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "43", + "green" : "43", + "red" : "43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Profile/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/bio.edit.background.gray.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/bio.edit.background.gray.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/name.edit.background.gray.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/name.edit.background.gray.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Profile/Banner/username.gray.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Banner/username.gray.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Profile/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/background.cyan.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/background.cyan.colorset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/cloud.base.imageset/untitled10007Group61@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.on.airplane.with.contrail.imageset/untitled10006Group21@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.imageset/untitled10003Group11@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.three.imageset/untitled10005Group101@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@2x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/illustration/elephant.three.on.grass.with.tree.two.imageset/untitled10004Group111@3x.png diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.imageset/mastodon.logo.black.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.black.large.imageset/mastodon.logo.black.large.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/logotypeFull1.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.imageset/logotypeFull1.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.imageset/logotypeFull1.pdf diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/Contents.json diff --git a/Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf b/Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf rename to Mastodon/Resources/Assets.xcassets/Scene/Welcome/mastodon.logo.large.imageset/logotypeFull1.large.pdf diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index f8e5c516d..c35d6e633 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -86,10 +86,12 @@ uploaded to Mastodon."; "Scene.Compose.Poll.DurationTime" = "Duration: %@"; "Scene.Compose.Poll.OneDay" = "1 Day"; "Scene.Compose.Poll.OneHour" = "1 Hour"; +"Scene.Compose.Poll.OptionNumber" = "Option %ld"; "Scene.Compose.Poll.SevenDays" = "7 Days"; "Scene.Compose.Poll.SixHours" = "6 Hours"; "Scene.Compose.Poll.ThirtyMinutes" = "30 minutes"; "Scene.Compose.Poll.ThreeDays" = "3 Days"; +"Scene.Compose.ReplyingToUser" = "replying to %@"; "Scene.Compose.Title.NewPost" = "New Post"; "Scene.Compose.Title.NewReply" = "New Reply"; "Scene.Compose.Visibility.Direct" = "Only people I mention"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 0163a54cd..9d84653e6 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -6,9 +6,22 @@ // import UIKit +import Combine final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCell { + var disposeBag = Set() + + let statusView = StatusView() + + override func prepareForReuse() { + super.prepareForReuse() + + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + disposeBag.removeAll() + } + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -24,7 +37,19 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { + backgroundColor = .clear + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Scene.Compose.background.color + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor), + ]) + statusView.actionToolbarContainer.isHidden = true } } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 3e82cd51a..e68be7295 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -31,7 +31,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) button.setTitleColor(.white, for: .normal) - button.contentEdgeInsets = UIEdgeInsets(top: 3, left: 16, bottom: 3, right: 16) + button.contentEdgeInsets = UIEdgeInsets(top: 5.5, left: 16, bottom: 5.5, right: 16) // set 28pt height button.adjustsImageWhenHighlighted = false return button }() @@ -66,18 +66,18 @@ final class ComposeViewController: UIViewController, NeedsDependency { return view }() - let composeToolbarView: ComposeToolbarView = { - let composeToolbarView = ComposeToolbarView() - let text = UITextView() - let inputView = UIInputView(frame: .init(x: 0, y: 0, width: 40, height: 40), inputViewStyle: .keyboard) - text.inputAccessoryView = inputView - composeToolbarView.backgroundColor = inputView.backgroundColor - return composeToolbarView - }() + let composeToolbarView = ComposeToolbarView() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeToolbarBackgroundView: UIView = { let backgroundView = UIView() - backgroundView.backgroundColor = .secondarySystemBackground + // set keyboard background to make the keyboard blurred color fixed + backgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in + // avoid elevated color + switch traitCollection.userInterfaceStyle { + case .light: return .white + default: return .black + } + }) return backgroundView }() @@ -135,7 +135,7 @@ extension ComposeViewController { self.title = title } .store(in: &disposeBag) - view.backgroundColor = Asset.Colors.Background.systemBackground.color + view.backgroundColor = Asset.Scene.Compose.background.color navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) navigationItem.rightBarButtonItem = publishBarButtonItem publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) @@ -266,13 +266,17 @@ extension ComposeViewController { .store(in: &disposeBag) // bind visibility toolbar UI - viewModel.selectedStatusVisibility - .receive(on: DispatchQueue.main) - .sink { [weak self] type in - guard let self = self else { return } - self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) - } - .store(in: &disposeBag) + Publishers.CombineLatest( + viewModel.selectedStatusVisibility, + viewModel.traitCollectionDidChangePublisher + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] type, _ in + guard let self = self else { return } + let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) + self.composeToolbarView.visibilityButton.setImage(image, for: .normal) + } + .store(in: &disposeBag) viewModel.characterCount .receive(on: DispatchQueue.main) @@ -336,6 +340,12 @@ extension ComposeViewController { } } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + viewModel.traitCollectionDidChangePublisher.send() + } + } extension ComposeViewController { diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index f52c38a17..1043d8bec 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -29,6 +29,7 @@ final class ComposeViewModel { let selectedStatusVisibility = CurrentValueSubject(.public) let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject + let traitCollectionDidChangePublisher = PassthroughSubject() // output var diffableDataSource: UICollectionViewDiffableDataSource! diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index efe408265..18aa0c0ba 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -80,8 +80,12 @@ final class ComposeToolbarView: UIView { } extension ComposeToolbarView { + private func _init() { - backgroundColor = .secondarySystemBackground + // magic keyboard color (iOS 14): + // light with white background: RGB 214 216 222 + // dark with black background: RGB 43 43 43 + backgroundColor = Asset.Scene.Compose.toolbarBackground.color let stackView = UIStackView() stackView.axis = .horizontal @@ -125,9 +129,18 @@ extension ComposeToolbarView { pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) emojiButton.addTarget(self, action: #selector(ComposeToolbarView.emojiButtonDidPressed(_:)), for: .touchUpInside) contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside) - visibilityButton.menu = createVisibilityContextMenu() + visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) visibilityButton.showsMenuAsPrimaryAction = true + + updateToolbarButtonUserInterfaceStyle() } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateToolbarButtonUserInterfaceStyle() + } + } extension ComposeToolbarView { @@ -152,9 +165,15 @@ extension ComposeToolbarView { } } - var image: UIImage { + func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage { switch self { - case .public: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + case .public: + switch interfaceStyle { + case .light: + return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + default: + return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + } case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! @@ -182,6 +201,25 @@ extension ComposeToolbarView { button.layer.cornerCurve = .continuous } + private func updateToolbarButtonUserInterfaceStyle() { + switch traitCollection.userInterfaceStyle { + case .light: + mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + emojiButton.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + + case .dark: + mediaButton.setImage(UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + emojiButton.setImage(UIImage(systemName: "face.smiling.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) + + default: + assertionFailure() + } + + visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle) + } + private func createMediaContextMenu() -> UIMenu { var children: [UIMenuElement] = [] let photoLibraryAction = UIAction(title: L10n.Scene.Compose.MediaSelection.photoLibrary, image: UIImage(systemName: "rectangle.on.rectangle"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in @@ -208,9 +246,9 @@ extension ComposeToolbarView { return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children) } - private func createVisibilityContextMenu() -> UIMenu { + private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu { let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in - UIAction(title: type.title, image: type.image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in + UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] action in guard let self = self else { return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue) self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type) diff --git a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift index 9512ea78b..f5d8c41c8 100644 --- a/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift +++ b/Mastodon/Scene/Onboarding/Welcome/View/WelcomeIllustrationView.swift @@ -16,14 +16,14 @@ final class WelcomeIllustrationView: UIView { let leftHillImageView = UIImageView() let centerHillImageView = UIImageView() - private let cloudBaseImage = Asset.Welcome.Illustration.cloudBase.image - private let elephantThreeOnGrassWithTreeTwoImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image - private let elephantThreeOnGrassWithTreeThreeImage = Asset.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image - private let elephantThreeOnGrassImage = Asset.Welcome.Illustration.elephantThreeOnGrass.image + private let cloudBaseImage = Asset.Scene.Welcome.Illustration.cloudBase.image + private let elephantThreeOnGrassWithTreeTwoImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeTwo.image + private let elephantThreeOnGrassWithTreeThreeImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrassWithTreeThree.image + private let elephantThreeOnGrassImage = Asset.Scene.Welcome.Illustration.elephantThreeOnGrass.image // layout outside let elephantOnAirplaneWithContrailImageView: UIImageView = { - let imageView = UIImageView(image: Asset.Welcome.Illustration.elephantOnAirplaneWithContrail.image) + let imageView = UIImageView(image: Asset.Scene.Welcome.Illustration.elephantOnAirplaneWithContrail.image) imageView.contentMode = .scaleAspectFill return imageView }() @@ -43,7 +43,7 @@ final class WelcomeIllustrationView: UIView { extension WelcomeIllustrationView { private func _init() { - backgroundColor = Asset.Welcome.Illustration.backgroundCyan.color + backgroundColor = Asset.Scene.Welcome.Illustration.backgroundCyan.color let topPaddingView = UIView() diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index c647d04ca..de89cd457 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -17,7 +17,7 @@ final class WelcomeViewController: UIViewController, NeedsDependency { var welcomeIllustrationViewBottomAnchorLayoutConstraint: NSLayoutConstraint? private(set) lazy var logoImageView: UIImageView = { - let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Welcome.mastodonLogo.image : Asset.Welcome.mastodonLogoBlackLarge.image + let image = view.traitCollection.userInterfaceIdiom == .phone ? Asset.Scene.Welcome.mastodonLogo.image : Asset.Scene.Welcome.mastodonLogoBlackLarge.image let imageView = UIImageView(image: image) imageView.translatesAutoresizingMaskIntoConstraints = false return imageView diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 2fba55e66..09d99c51e 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -100,7 +100,7 @@ final class ProfileHeaderView: UIView { label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 - label.textColor = Asset.Profile.Banner.usernameGray.color + label.textColor = Asset.Scene.Profile.Banner.usernameGray.color label.text = "@alice" label.applyShadow(color: UIColor.black.withAlphaComponent(0.2), alpha: 0.5, x: 0, y: 2, blur: 2, spread: 0) return label @@ -131,7 +131,7 @@ final class ProfileHeaderView: UIView { textEditorView.scrollView.isScrollEnabled = false textEditorView.isScrollEnabled = false textEditorView.font = .preferredFont(forTextStyle: .body) - textEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + textEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color textEditorView.layer.masksToBounds = true textEditorView.layer.cornerCurve = .continuous textEditorView.layer.cornerRadius = 10 @@ -356,9 +356,9 @@ extension ProfileHeaderView { bioTextEditorView.backgroundColor = .clear animator.addAnimations { self.bannerImageViewOverlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor - self.nameTextFieldBackgroundView.backgroundColor = Asset.Profile.Banner.nameEditBackgroundGray.color + self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color self.editAvatarBackgroundView.alpha = 1 - self.bioTextEditorView.backgroundColor = Asset.Profile.Banner.bioEditBackgroundGray.color + self.bioTextEditorView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index fc0fda099..63e8bb8f1 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -29,7 +29,7 @@ final class StatusView: UIView { static let avatarToLabelSpacing: CGFloat = 5 static let contentWarningBlurRadius: CGFloat = 12 - static let boostIconImage: UIImage = { + static let reblogIconImage: UIImage = { let font = UIFont.systemFont(ofSize: 13, weight: .medium) let configuration = UIImage.SymbolConfiguration(font: font) let image = UIImage(systemName: "arrow.2.squarepath", withConfiguration: configuration)!.withTintColor(Asset.Colors.Label.secondary.color) @@ -61,7 +61,7 @@ final class StatusView: UIView { let headerIconLabel: UILabel = { let label = UILabel() - label.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) return label }() From 66b07f41dbb0f33724517d1f9236069250555950 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 15:00:48 +0800 Subject: [PATCH 234/400] chore: display status Content --- .../Section/NotificationSection.swift | 377 +++++++++++++++++- Mastodon/Generated/Assets.swift | 1 + .../notification.colorset/Contents.json | 38 ++ .../NotificationViewController.swift | 14 +- .../NotificationViewModel+diffable.swift | 13 +- .../Notification/NotificationViewModel.swift | 5 + .../NotificationTableViewCell.swift | 71 +++- 7 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 277a40f50..09f0f87a2 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -20,13 +20,19 @@ extension NotificationSection { static func tableViewDiffableDataSource( for tableView: UITableView, timestampUpdatePublisher: AnyPublisher, - managedObjectContext: NSManagedObjectContext + managedObjectContext: NSManagedObjectContext, + delegate: NotificationTableViewCellDelegate, + dependency: NeedsDependency, + requestUserID: String ) -> UITableViewDiffableDataSource { - - return UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, notificationItem) -> UITableViewCell? in + return UITableViewDiffableDataSource(tableView: tableView) { + [weak delegate,weak dependency] + (tableView, indexPath, notificationItem) -> UITableViewCell? in + guard let dependency = dependency else { return nil } switch notificationItem { case .notification(let objectID): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + cell.delegate = delegate let notification = managedObjectContext.object(with: objectID) as! MastodonNotification let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type) @@ -69,7 +75,7 @@ extension NotificationSection { let timeText = notification.createAt.shortTimeAgoSinceNow cell.actionImageBackground.backgroundColor = color cell.actionLabel.text = actionText + " · " + timeText - cell.nameLabel.text = notification.account.displayName + cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName cell.avatatImageView.af.setImage( withURL: URL(string: notification.account.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), @@ -79,10 +85,18 @@ extension NotificationSection { if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } - if let _ = notification.status { - cell.nameLabelLayoutIn(center: true) - } else { + if let status = notification.status { + let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - 61 - 2, height: tableView.readableContentGuide.layoutFrame.height) + NotificationSection.configure(cell: cell, + dependency: dependency, + readableLayoutFrame: frame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: "", + statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false)) cell.nameLabelLayoutIn(center: false) + } else { + cell.nameLabelLayoutIn(center: true) } return cell case .bottomLoader: @@ -93,3 +107,352 @@ extension NotificationSection { } } } + + +extension NotificationSection { + static func configure( + cell: NotificationTableViewCell, + dependency: NeedsDependency, + readableLayoutFrame: CGRect?, + timestampUpdatePublisher: AnyPublisher, + status: Status, + requestUserID: String, + statusItemAttribute: Item.StatusAttribute + ) { + // disable interaction + cell.statusView.isUserInteractionEnabled = false + // remove actionToolBar + cell.statusView.actionToolbarContainer.removeFromSuperview() + // setup attribute + statusItemAttribute.setupForStatus(status: status) + + // set header + NotificationSection.configureHeader(cell: cell, status: status) + ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newStatus = object as? Status else { return } + NotificationSection.configureHeader(cell: cell, status: newStatus) + } + .store(in: &cell.disposeBag) + + // set name username + cell.statusView.nameLabel.text = { + let author = (status.reblog ?? status).author + return author.displayName.isEmpty ? author.username : author.displayName + }() + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set avatar + + cell.statusView.avatarButton.isHidden = false + cell.statusView.avatarStackedContainerButton.isHidden = true + cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + + + // set text + cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) + + // set status text content warning + let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false + let spoilerText = (status.reblog ?? status).spoilerText ?? "" + cell.statusView.isStatusTextSensitive = isStatusTextSensitive + cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) + cell.statusView.contentWarningTitle.text = { + if spoilerText.isEmpty { + return L10n.Common.Controls.Status.statusContentWarning + } else { + return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" + } + }() + + // prepare media attachments + let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } + + // set image + let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) + let imageViewMaxSize: CGSize = { + let maxWidth: CGFloat = { + // use timelinePostView width as container width + // that width follows readable width and keep constant width after rotate + let containerFrame = readableLayoutFrame ?? cell.statusView.frame + var containerWidth = containerFrame.width + containerWidth -= 10 + containerWidth -= StatusView.avatarImageSize.width + return containerWidth + }() + let scale: CGFloat = { + switch mosiacImageViewModel.metas.count { + case 1: return 1.3 + default: return 0.7 + } + }() + return CGSize(width: maxWidth, height: maxWidth * scale) + }() + if mosiacImageViewModel.metas.count == 1 { + let meta = mosiacImageViewModel.metas[0] + let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + imageView.af.setImage( + withURL: meta.url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } else { + let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + for (i, imageView) in imageViews.enumerated() { + let meta = mosiacImageViewModel.metas[i] + imageView.af.setImage( + withURL: meta.url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + } + cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty + let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive + + // set audio + if let _ = mediaAttachments.filter({ $0.type == .audio }).first { + cell.statusView.audioView.isHidden = false + cell.statusView.audioView.playButton.isSelected = false + cell.statusView.audioView.slider.isEnabled = false + cell.statusView.audioView.slider.setValue(0, animated: false) + } else { + cell.statusView.audioView.isHidden = true + } + + // set GIF & video + let playerViewMaxSize: CGSize = { + let maxWidth: CGFloat = { + // use statusView width as container width + // that width follows readable width and keep constant width after rotate + let containerFrame = readableLayoutFrame ?? cell.statusView.frame + return containerFrame.width + }() + let scale: CGFloat = 1.3 + return CGSize(width: maxWidth, height: maxWidth * scale) + }() + + cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil + cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 + cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive + + if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, + let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) + { + let parent = cell.delegate?.parent() + let playerContainerView = cell.statusView.playerContainerView + let playerViewController = playerContainerView.setupPlayer( + aspectRatio: videoPlayerViewModel.videoSize, + maxSize: playerViewMaxSize, + parent: parent + ) + playerViewController.player = videoPlayerViewModel.player + playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif + playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) + if videoPlayerViewModel.videoKind == .gif { + playerContainerView.setMediaIndicator(isHidden: false) + } else { + videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in + UIView.animate(withDuration: 0.33) { + switch timeControlStatus { + case .playing: + playerContainerView.setMediaIndicator(isHidden: true) + case .paused, .waitingToPlayAtSpecifiedRate: + playerContainerView.setMediaIndicator(isHidden: false) + @unknown default: + assertionFailure() + } + } + } + .store(in: &cell.disposeBag) + } + playerContainerView.isHidden = false + + } else { + cell.statusView.playerContainerView.playerViewController.player?.pause() + cell.statusView.playerContainerView.playerViewController.player = nil + } + // set poll + let poll = (status.reblog ?? status).poll + NotificationSection.configurePoll( + cell: cell, + poll: poll, + requestUserID: requestUserID, + updateProgressAnimated: false, + timestampUpdatePublisher: timestampUpdatePublisher + ) + if let poll = poll { + ManagedObjectObserver.observe(object: poll) + .sink { _ in + // do nothing + } receiveValue: { change in + guard case .update(let object) = change.changeType, + let newPoll = object as? Poll else { return } + NotificationSection.configurePoll( + cell: cell, + poll: newPoll, + requestUserID: requestUserID, + updateProgressAnimated: true, + timestampUpdatePublisher: timestampUpdatePublisher + ) + } + .store(in: &cell.disposeBag) + } + + // set date + let createdAt = (status.reblog ?? status).createdAt + cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow + timestampUpdatePublisher + .sink { _ in + cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow + } + .store(in: &cell.disposeBag) + + } + + static func configureHeader( + cell: NotificationTableViewCell, + status: Status + ) { + if status.reblog != nil { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.boostIconImage) + cell.statusView.headerInfoLabel.text = { + let author = status.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userReblogged(name) + }() + } else if let replyTo = status.replyTo { + cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) + cell.statusView.headerInfoLabel.text = { + let author = replyTo.author + let name = author.displayName.isEmpty ? author.username : author.displayName + return L10n.Common.Controls.Status.userRepliedTo(name) + }() + } else { + cell.statusView.headerContainerStackView.isHidden = true + } + } + + + static func configurePoll( + cell: NotificationTableViewCell, + poll: Poll?, + requestUserID: String, + updateProgressAnimated: Bool, + timestampUpdatePublisher: AnyPublisher + ) { + guard let poll = poll, + let managedObjectContext = poll.managedObjectContext + else { + cell.statusView.pollTableView.isHidden = true + cell.statusView.pollStatusStackView.isHidden = true + cell.statusView.pollVoteButton.isHidden = true + return + } + + cell.statusView.pollTableView.isHidden = false + cell.statusView.pollStatusStackView.isHidden = false + cell.statusView.pollVoteCountLabel.text = { + if poll.multiple { + let count = poll.votersCount?.intValue ?? 0 + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoterCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) + } + } else { + let count = poll.votesCount.intValue + if count > 1 { + return L10n.Common.Controls.Status.Poll.VoteCount.single(count) + } else { + return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) + } + } + }() + + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed + + + cell.statusView.pollTableView.allowsSelection = !poll.expired + + let votedOptions = poll.options.filter { option in + (option.votedBy ?? Set()).map(\.id).contains(requestUserID) + } + let didVotedLocal = !votedOptions.isEmpty + let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) + cell.statusView.pollVoteButton.isEnabled = didVotedLocal + cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) + + cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( + for: cell.statusView.pollTableView, + managedObjectContext: managedObjectContext + ) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + let pollItems = poll.options + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .map { option -> PollItem in + let attribute: PollItem.Attribute = { + let selectState: PollItem.Attribute.SelectState = { + // check didVotedRemote later to make the local change possible + if !votedOptions.isEmpty { + return votedOptions.contains(option) ? .on : .off + } else if poll.expired { + return .none + } else if didVotedRemote, votedOptions.isEmpty { + return .none + } else { + return .off + } + }() + let voteState: PollItem.Attribute.VoteState = { + var needsReveal: Bool + if poll.expired { + needsReveal = true + } else if didVotedRemote { + needsReveal = true + } else { + needsReveal = false + } + guard needsReveal else { return .hidden } + let percentage: Double = { + guard poll.votesCount.intValue > 0 else { return 0.0 } + return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) + }() + let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) + return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) + }() + return PollItem.Attribute(selectState: selectState, voteState: voteState) + }() + let option = PollItem.opion(objectID: option.objectID, attribute: attribute) + return option + } + snapshot.appendItems(pollItems, toSection: .main) + cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) + } + + static func configureEmptyStateHeader( + cell: TimelineHeaderTableViewCell, + attribute: Item.EmptyStateHeaderAttribute + ) { + cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage + cell.timelineHeaderView.messageLabel.text = attribute.reason.message + } +} + +extension NotificationSection { + private static func formattedNumberTitleForActionButton(_ number: Int?) -> String { + guard let number = number, number > 0 else { return "" } + return String(number) + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ef7ae9292..a9963ce9c 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -53,6 +53,7 @@ internal enum Asset { internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") } internal enum Border { + internal static let notification = ColorAsset(name: "Colors/Border/notification") internal static let searchCard = ColorAsset(name: "Colors/Border/searchCard") } internal enum Button { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json new file mode 100644 index 000000000..afc18df10 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Border/notification.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE8", + "green" : "0xE1", + "red" : "0xD9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "60", + "green" : "58", + "red" : "58" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index b87172925..5a7009270 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -8,6 +8,8 @@ import UIKit import Combine import OSLog +import CoreData +import CoreDataStack final class NotificationViewController: UIViewController, NeedsDependency { @@ -58,7 +60,7 @@ extension NotificationViewController { tableView.delegate = self viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self - viewModel.setupDiffableDataSource(for: tableView) + viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self) viewModel.viewDidLoad.send() // bind refresh control viewModel.isFetchingLatestNotification @@ -125,10 +127,6 @@ extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return 68 } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 68 - } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate @@ -138,6 +136,12 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl } } +extension NotificationViewController: NotificationTableViewCellDelegate { + func parent() -> UIViewController { + self + } +} + //// MARK: - UIScrollViewDelegate //extension NotificationViewController { // func scrollViewDidScroll(_ scrollView: UIScrollView) { diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index 1d77d41b4..c9c0dcf6f 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -13,17 +13,24 @@ import CoreDataStack extension NotificationViewModel { func setupDiffableDataSource( - for tableView: UITableView + for tableView: UITableView, + delegate: NotificationTableViewCellDelegate, + dependency: NeedsDependency ) { let timestampUpdatePublisher = Timer.publish(every: 30.0, on: .main, in: .common) .autoconnect() .share() .eraseToAnyPublisher() - + guard let userid = activeMastodonAuthenticationBox.value?.userID else { + return + } diffableDataSource = NotificationSection.tableViewDiffableDataSource( for: tableView, timestampUpdatePublisher: timestampUpdatePublisher, - managedObjectContext: context.managedObjectContext + managedObjectContext: context.managedObjectContext, + delegate: delegate, + dependency: dependency, + requestUserID: userid ) } diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 1b4890317..b8d74152c 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -85,7 +85,12 @@ final class NotificationViewModel: NSObject { guard let self = self else { return } self.fetchedResultsController.fetchRequest.predicate = predicate do { + self.diffableDataSource?.defaultRowAnimation = .fade try self.fetchedResultsController.performFetch() + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + guard let self = self else { return } + self.diffableDataSource?.defaultRowAnimation = .automatic + } } catch { assertionFailure(error.localizedDescription) } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index b252b76d8..d7decfddf 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -5,17 +5,23 @@ // Created by sxiaojian on 2021/4/13. // +import Combine import Foundation import UIKit -import Combine +protocol NotificationTableViewCellDelegate: class { + var context: AppContext! { get } + + func parent() -> UIViewController +} final class NotificationTableViewCell: UITableViewCell { - static let actionImageBorderWidth: CGFloat = 2 var disposeBag = Set() + var delegate: NotificationTableViewCellDelegate? + let avatatImageView: UIImageView = { let imageView = UIImageView() imageView.layer.cornerRadius = 4 @@ -32,7 +38,7 @@ final class NotificationTableViewCell: UITableViewCell { let actionImageBackground: UIView = { let view = UIView() - view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth)/2 + view.layer.cornerRadius = (24 + NotificationTableViewCell.actionImageBorderWidth) / 2 view.layer.cornerCurve = .continuous view.clipsToBounds = true view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth @@ -58,11 +64,30 @@ final class NotificationTableViewCell: UITableViewCell { }() var nameLabelTop: NSLayoutConstraint! + var nameLabelBottom: NSLayoutConstraint! + + let statusContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.cornerRadius = 6 + view.layer.borderWidth = 2 + view.layer.cornerCurve = .continuous + view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + view.clipsToBounds = true + return view + }() + + let statusView = StatusView() override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() - + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + statusView.playerContainerView.isHidden = true + disposeBag.removeAll() } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -74,11 +99,19 @@ final class NotificationTableViewCell: UITableViewCell { super.init(coder: coder) configure() } + + override func layoutSubviews() { + super.layoutSubviews() + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } } extension NotificationTableViewCell { - func configure() { + selectionStyle = .none + contentView.addSubview(avatatImageView) avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) @@ -90,7 +123,8 @@ extension NotificationTableViewCell { actionImageBackground.addSubview(actionImageView) actionImageView.constrainToCenter() - nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor) + nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24) + nameLabelBottom = contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24) contentView.addSubview(nameLabel) nameLabel.constrain([ nameLabelTop, @@ -100,21 +134,38 @@ extension NotificationTableViewCell { contentView.addSubview(actionLabel) actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), - actionLabel.topAnchor.constraint(equalTo: nameLabel.topAnchor), - contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4) + actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), + contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) ]) + + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + } public func nameLabelLayoutIn(center: Bool) { if center { nameLabelTop.constant = 24 + NSLayoutConstraint.activate([nameLabelBottom]) + statusView.removeFromSuperview() + statusContainer.removeFromSuperview() } else { nameLabelTop.constant = 12 + NSLayoutConstraint.deactivate([nameLabelBottom]) + addStatusAndContainer() } } + + func addStatusAndContainer() { + contentView.addSubview(statusContainer) + statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) + + contentView.addSubview(statusView) + statusView.pin(top: 40 + 12, left: 63 + 12, bottom: 14 + 12, right: 14 + 12) + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - - self.actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor } } From d99fb1af7610aa911eb7f397e11d4c40c84e5661 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 15:56:06 +0800 Subject: [PATCH 235/400] chore: make segment work in notification --- .../Notification/NotificationViewController.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 5a7009270..2c96c65d6 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -10,6 +10,7 @@ import Combine import OSLog import CoreData import CoreDataStack +import MastodonSDK final class NotificationViewController: UIViewController, NeedsDependency { @@ -22,7 +23,6 @@ final class NotificationViewController: UIViewController, NeedsDependency { let segmentControl: UISegmentedControl = { let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything,L10n.Scene.Notification.Title.mentions]) control.selectedSegmentIndex = 0 - control.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .touchUpInside) return control }() @@ -33,6 +33,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) tableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) + tableView.tableFooterView = UIView() return tableView }() @@ -46,6 +47,7 @@ extension NotificationViewController { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.searchResult.color navigationItem.titleView = segmentControl + segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) view.addSubview(tableView) tableView.constrain([ tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), @@ -111,6 +113,14 @@ extension NotificationViewController { extension NotificationViewController { @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) { os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) + guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain else { + return + } + if sender.selectedSegmentIndex == 0 { + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + } else { + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, type: Mastodon.Entity.Notification.NotificationType.mention.rawValue) + } } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { From d5c9473528b34a50ceb82cede013db807dda3ea8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 15:59:29 +0800 Subject: [PATCH 236/400] feat: implement reply status entry and update query of API --- .../CoreData.xcdatamodel/contents | 7 ++-- CoreDataStack/Entity/Mention.swift | 7 +++- .../Section/ComposeStatusSection.swift | 12 +++++++ Mastodon/Helper/MastodonField.swift | 2 +- ...Provider+StatusTableViewCellDelegate.swift | 4 +++ .../StatusProvider/StatusProviderFacade.swift | 32 ++++++++++++++++++- .../StatusTableViewControllerAspect.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../ComposeViewModel+PublishState.swift | 11 +++++++ Mastodon/Scene/Compose/ComposeViewModel.swift | 31 +++++++++++++++++- .../HashtagTimelineViewController.swift | 4 +++ .../Favorite/FavoriteViewController.swift | 4 +++ .../TableviewCell/StatusTableViewCell.swift | 11 ++++--- .../Scene/Thread/ThreadViewController.swift | 7 ++-- .../CoreData/APIService+CoreData+Status.swift | 4 +-- .../API/Mastodon+API+Statuses.swift | 5 ++- 16 files changed, 126 insertions(+), 19 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 5ed4021a7..eb095669a 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -115,6 +115,7 @@ + @@ -209,7 +210,7 @@ - + @@ -217,4 +218,4 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Mention.swift b/CoreDataStack/Entity/Mention.swift index 9559ea5d5..864ca4948 100644 --- a/CoreDataStack/Entity/Mention.swift +++ b/CoreDataStack/Entity/Mention.swift @@ -10,6 +10,9 @@ import Foundation public final class Mention: NSManagedObject { public typealias ID = UUID + + @NSManaged public private(set) var index: NSNumber + @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var id: String @NSManaged public private(set) var createAt: Date @@ -32,9 +35,11 @@ public extension Mention { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + index: Int ) -> Mention { let mention: Mention = context.insertObject() + mention.index = NSNumber(value: index) mention.id = property.id mention.username = property.username mention.acct = property.acct diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index c8a8bc180..4b0b5aa57 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -57,7 +57,19 @@ extension ComposeStatusSection { return } let status = replyTo.reblog ?? replyTo + + // set avatar cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) + // set name username + cell.statusView.nameLabel.text = { + let author = status.author + return author.displayName.isEmpty ? author.username : author.displayName + }() + cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set text + cell.statusView.activeTextLabel.configure(content: status.content) + // set date + cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow } return cell case .input(let replyToStatusObjectID, let attribute): diff --git a/Mastodon/Helper/MastodonField.swift b/Mastodon/Helper/MastodonField.swift index e828602e4..5f652b32c 100644 --- a/Mastodon/Helper/MastodonField.swift +++ b/Mastodon/Helper/MastodonField.swift @@ -11,7 +11,7 @@ import ActiveLabel enum MastodonField { static func parse(field string: String) -> ParseResult { - let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?)") + let mentionMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?)") let hashtagMatches = string.matches(pattern: "(?:#([^\\s.]+))") let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 2983a6f96..25322e216 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -33,6 +33,10 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) { + StatusProviderFacade.responseToStatusReplyAction(provider: self, cell: cell) + } + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { StatusProviderFacade.responseToStatusReblogAction(provider: self, cell: cell) } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 6db861ec6..0e26614c5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -277,7 +277,6 @@ extension StatusProviderFacade { } extension StatusProviderFacade { - static func responseToStatusReblogAction(provider: StatusProvider) { _responseToStatusReblogAction( @@ -385,6 +384,37 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + static func responseToStatusReplyAction(provider: StatusProvider) { + _responseToStatusReplyAction( + provider: provider, + status: provider.status() + ) + } + + static func responseToStatusReplyAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusReplyAction( + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusReplyAction(provider: StatusProvider, status: Future) { + status + .sink { [weak provider] status in + guard let provider = provider else { return } + guard let status = status?.reblog ?? status else { return } + + let composeViewModel = ComposeViewModel(context: provider.context, composeKind: .reply(repliedToStatusObjectID: status.objectID)) + provider.coordinator.present(scene: .compose(viewModel: composeViewModel), from: provider, transition: .modal(animated: true, completion: nil)) + } + .store(in: &provider.context.disposeBag) + + } + +} + extension StatusProviderFacade { enum Target { case primary // original status diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index 77b1e17ba..f96998ea6 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -10,7 +10,7 @@ import AVKit // Check List Last Updated // - HomeViewController: 2021/4/13 -// - FavoriteViewController: 2021/4/8 +// - FavoriteViewController: 2021/4/14 // - HashtagTimelineViewController: 2021/4/8 // - UserTimelineViewController: 2021/4/13 // - ThreadViewController: 2021/4/13 diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index e68be7295..29b8850b9 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -548,7 +548,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) let stringRange = NSRange(location: 0, length: string.length) - let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.]+)?|#([^\\s.]+))") + let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))") // accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect // precondition :\B with following space let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))") diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index c3e903812..fd3f5bce0 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -8,6 +8,7 @@ import os.log import Foundation import Combine +import CoreDataStack import GameplayKit import MastodonSDK @@ -64,6 +65,15 @@ extension ComposeViewModel.PublishState { guard viewModel.isPollComposing.value else { return nil } return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds }() + let inReplyToID: Mastodon.Entity.Status.ID? = { + guard case let .reply(repliedToStatusObjectID) = viewModel.composeKind else { return nil } + var id: Mastodon.Entity.Status.ID? + viewModel.context.managedObjectContext.performAndWait { + guard let replyTo = viewModel.context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + id = replyTo.id + } + return id + }() let sensitive: Bool = viewModel.isContentWarningComposing.value let spoilerText: String? = { let text = viewModel.composeStatusAttribute.contentWarningContent.value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -105,6 +115,7 @@ extension ComposeViewModel.PublishState { mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, pollOptions: pollOptions, pollExpiresIn: pollExpiresIn, + inReplyToID: inReplyToID, sensitive: sensitive, spoilerText: spoilerText, visibility: visibility diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 1043d8bec..56efd2529 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -87,7 +87,36 @@ final class ComposeViewModel { self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init - if case let .hashtag(text) = composeKind { + if case let .reply(repliedToStatusObjectID) = composeKind { + context.managedObjectContext.performAndWait { + guard let status = context.managedObjectContext.object(with: repliedToStatusObjectID) as? Status else { return } + let composeAuthor: MastodonUser? = { + guard let objectID = self.activeAuthentication.value?.user.objectID else { return nil } + guard let author = context.managedObjectContext.object(with: objectID) as? MastodonUser else { return nil } + return author + }() + + var mentionAccts: [String] = [] + if composeAuthor?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + let mentions = (status.mentions ?? Set()) + .sorted(by: { $0.index.intValue < $1.index.intValue }) + .filter { $0.id != composeAuthor?.id } + for mention in mentions { + mentionAccts.append("@" + mention.acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + + let initialComposeContent = mentionAccts.joined(separator: " ") + let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " + self.preInsertedContent = preInsertedContent + self.composeStatusAttribute.composeContent.value = preInsertedContent + } + + } else if case let .hashtag(text) = composeKind { let initialComposeContent = "#" + text UITextChecker.learnWord(initialComposeContent) let preInsertedContent = initialComposeContent + " " diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index c9bf87410..4b45d6638 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -218,6 +218,10 @@ extension HashtagTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index a175ae348..8205d5a2e 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -114,6 +114,10 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, didEndDisplaying: cell, forRowAt: indexPath) } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 39916741e..10fcdca3c 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -32,6 +32,7 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, playerViewControllerDidPressed playerViewController: AVPlayerViewController) + func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, replyButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, actionToolbarContainer: ActionToolbarContainer, likeButtonDidPressed sender: UIButton) @@ -302,19 +303,21 @@ extension StatusTableViewCell: MosaicImageViewContainerDelegate { // MARK: - ActionToolbarContainerDelegate extension StatusTableViewCell: ActionToolbarContainerDelegate { + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) { - + delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, replyButtonDidPressed: sender) } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, reblogButtonDidPressed: sender) } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, bookmarkButtonDidPressed sender: UIButton) { - - } + func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { } + } diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 43c40025e..db7c76a75 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -88,8 +88,6 @@ extension ThreadViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // force readable layout frame update - tableView.reloadData() aspectViewWillAppear(animated) } @@ -104,7 +102,10 @@ extension ThreadViewController { extension ThreadViewController { @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let rootItem = viewModel.rootItem.value, + case let .root(statusObjectID, _) = rootItem else { return } + let composeViewModel = ComposeViewModel(context: context, composeKind: .reply(repliedToStatusObjectID: statusObjectID)) + coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index a05574b6b..328fa2305 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -86,8 +86,8 @@ extension APIService.CoreData { let object = Poll.insert(into: managedObjectContext, property: Poll.Property(id: poll.id, expiresAt: poll.expiresAt, expired: poll.expired, multiple: poll.multiple, votesCount: poll.votesCount, votersCount: poll.votersCount, networkDate: networkDate), votedBy: votedBy, options: options) return object } - let metions = entity.mentions?.compactMap { mention -> Mention in - Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url)) + let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in + Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index) } let emojis = entity.emojis?.compactMap { emoji -> Emoji in Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category)) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index ae5d5e670..bb5a4abfc 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -98,6 +98,7 @@ extension Mastodon.API.Statuses { public let mediaIDs: [String]? public let pollOptions: [String]? public let pollExpiresIn: Int? + public let inReplyToID: Mastodon.Entity.Status.ID? public let sensitive: Bool? public let spoilerText: String? public let visibility: Mastodon.Entity.Status.Visibility? @@ -107,6 +108,7 @@ extension Mastodon.API.Statuses { mediaIDs: [String]?, pollOptions: [String]?, pollExpiresIn: Int?, + inReplyToID: Mastodon.Entity.Status.ID?, sensitive: Bool?, spoilerText: String?, visibility: Mastodon.Entity.Status.Visibility? @@ -115,10 +117,10 @@ extension Mastodon.API.Statuses { self.mediaIDs = mediaIDs self.pollOptions = pollOptions self.pollExpiresIn = pollExpiresIn + self.inReplyToID = inReplyToID self.sensitive = sensitive self.spoilerText = spoilerText self.visibility = visibility - } var contentType: String? { @@ -136,6 +138,7 @@ extension Mastodon.API.Statuses { data.append(Data.multipart(key: "poll[options][]", value: pollOption)) } pollExpiresIn.flatMap { data.append(Data.multipart(key: "poll[expires_in]", value: $0)) } + inReplyToID.flatMap { data.append(Data.multipart(key: "in_reply_to_id", value: $0)) } sensitive.flatMap { data.append(Data.multipart(key: "sensitive", value: $0)) } spoilerText.flatMap { data.append(Data.multipart(key: "spoiler_text", value: $0)) } visibility.flatMap { data.append(Data.multipart(key: "visibility", value: $0.rawValue)) } From 56bd6d0ae8a557693d5f7d13cc5b24dd3fbeb84d Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 16:17:34 +0800 Subject: [PATCH 237/400] feat: set compose initial visibility following the account lock setting --- ...oseRepliedToStatusContentCollectionViewCell.swift | 2 +- Mastodon/Scene/Compose/ComposeViewModel.swift | 5 +++-- Mastodon/Scene/Compose/View/ComposeToolbarView.swift | 12 +++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 9d84653e6..51c6f2fd3 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -46,7 +46,7 @@ extension ComposeRepliedToStatusContentCollectionViewCell { statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), ]) statusView.actionToolbarContainer.isHidden = true diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 56efd2529..777059adb 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -26,10 +26,10 @@ final class ComposeViewModel { let isPollComposing = CurrentValueSubject(false) let isCustomEmojiComposing = CurrentValueSubject(false) let isContentWarningComposing = CurrentValueSubject(false) - let selectedStatusVisibility = CurrentValueSubject(.public) + let selectedStatusVisibility: CurrentValueSubject let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject - let traitCollectionDidChangePublisher = PassthroughSubject() + let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit // output var diffableDataSource: UICollectionViewDiffableDataSource! @@ -84,6 +84,7 @@ final class ComposeViewModel { case .post, .hashtag, .mention: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newPost) case .reply: self.title = CurrentValueSubject(L10n.Scene.Compose.Title.newReply) } + self.selectedStatusVisibility = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value?.user.locked == true ? .private : .public) self.activeAuthentication = CurrentValueSubject(context.authenticationService.activeMastodonAuthentication.value) self.activeAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) // end init diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 18aa0c0ba..9745ad954 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -169,14 +169,12 @@ extension ComposeToolbarView { switch self { case .public: switch interfaceStyle { - case .light: - return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! - default: - return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! + default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))! } - case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! - case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! - case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium))! + case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))! + case .private: return UIImage(systemName: "person.crop.circle.badge.plus", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))! + case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))! } } From 288a8025ced0af0f78e1472067e819e039303b84 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 16:24:40 +0800 Subject: [PATCH 238/400] chore: use NotificationStatusTableViewCell and NotificationTableViewCell --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/NotificationSection.swift | 81 ++++++---- .../NotificationViewController.swift | 1 + .../NotificationStatusTableViewCell.swift | 147 ++++++++++++++++++ .../NotificationTableViewCell.swift | 59 +------ 5 files changed, 206 insertions(+), 86 deletions(-) create mode 100644 Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b2258c1df..150c29afb 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -44,6 +44,7 @@ 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; }; 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; + 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; @@ -431,6 +432,7 @@ 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; + 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -898,6 +900,7 @@ isa = PBXGroup; children = ( 2D35238026256F690031AF25 /* NotificationTableViewCell.swift */, + 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -2363,6 +2366,7 @@ DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, + 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */, DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */, DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 09f0f87a2..de7f5d6c8 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -31,11 +31,12 @@ extension NotificationSection { guard let dependency = dependency else { return nil } switch notificationItem { case .notification(let objectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell - cell.delegate = delegate + let notification = managedObjectContext.object(with: objectID) as! MastodonNotification let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type) + let timeText = notification.createAt.shortTimeAgoSinceNow + var actionText: String var actionImageName: String var color: UIColor @@ -66,39 +67,59 @@ extension NotificationSection { color = .clear } - timestampUpdatePublisher - .sink { _ in - let timeText = notification.createAt.shortTimeAgoSinceNow - cell.actionLabel.text = actionText + " · " + timeText - } - .store(in: &cell.disposeBag) - let timeText = notification.createAt.shortTimeAgoSinceNow - cell.actionImageBackground.backgroundColor = color - cell.actionLabel.text = actionText + " · " + timeText - cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName - cell.avatatImageView.af.setImage( - withURL: URL(string: notification.account.avatar)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - - if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { - cell.actionImageView.image = actionImage - } if let status = notification.status { - let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - 61 - 2, height: tableView.readableContentGuide.layoutFrame.height) + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell + cell.delegate = delegate NotificationSection.configure(cell: cell, dependency: dependency, - readableLayoutFrame: frame, + readableLayoutFrame: nil, timestampUpdatePublisher: timestampUpdatePublisher, status: status, - requestUserID: "", + requestUserID: requestUserID, statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false)) - cell.nameLabelLayoutIn(center: false) + timestampUpdatePublisher + .sink { _ in + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionLabel.text = actionText + " · " + timeText + } + .store(in: &cell.disposeBag) + cell.actionImageBackground.backgroundColor = color + cell.actionLabel.text = actionText + " · " + timeText + cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName + cell.avatatImageView.af.setImage( + withURL: URL(string: notification.account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + + if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { + cell.actionImageView.image = actionImage + } + return cell + } else { - cell.nameLabelLayoutIn(center: true) + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationTableViewCell.self), for: indexPath) as! NotificationTableViewCell + cell.delegate = delegate + timestampUpdatePublisher + .sink { _ in + let timeText = notification.createAt.shortTimeAgoSinceNow + cell.actionLabel.text = actionText + " · " + timeText + } + .store(in: &cell.disposeBag) + cell.actionImageBackground.backgroundColor = color + cell.actionLabel.text = actionText + " · " + timeText + cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName + cell.avatatImageView.af.setImage( + withURL: URL(string: notification.account.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + + if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { + cell.actionImageView.image = actionImage + } + return cell } - return cell case .bottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader cell.startAnimating() @@ -111,7 +132,7 @@ extension NotificationSection { extension NotificationSection { static func configure( - cell: NotificationTableViewCell, + cell: NotificationStatusTableViewCell, dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, @@ -317,7 +338,7 @@ extension NotificationSection { } static func configureHeader( - cell: NotificationTableViewCell, + cell: NotificationStatusTableViewCell, status: Status ) { if status.reblog != nil { @@ -343,7 +364,7 @@ extension NotificationSection { static func configurePoll( - cell: NotificationTableViewCell, + cell: NotificationStatusTableViewCell, poll: Poll?, requestUserID: String, updateProgressAnimated: Bool, diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 2c96c65d6..d047a0a5d 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -32,6 +32,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) + tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) tableView.tableFooterView = UIView() return tableView diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift new file mode 100644 index 000000000..1a0df283e --- /dev/null +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -0,0 +1,147 @@ +// +// NotificationStatusTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import Combine +import Foundation +import UIKit + +final class NotificationStatusTableViewCell: UITableViewCell { + static let actionImageBorderWidth: CGFloat = 2 + + var disposeBag = Set() + + var delegate: NotificationTableViewCellDelegate? + + let avatatImageView: UIImageView = { + let imageView = UIImageView() + imageView.layer.cornerRadius = 4 + imageView.layer.cornerCurve = .continuous + imageView.clipsToBounds = true + return imageView + }() + + let actionImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Background.searchResult.color + return imageView + }() + + let actionImageBackground: UIView = { + let view = UIView() + view.layer.cornerRadius = (24 + NotificationStatusTableViewCell.actionImageBorderWidth) / 2 + view.layer.cornerCurve = .continuous + view.clipsToBounds = true + view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth + view.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + view.tintColor = Asset.Colors.Background.searchResult.color + return view + }() + + let actionLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFont.preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let nameLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 15, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let statusContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.layer.cornerRadius = 6 + view.layer.borderWidth = 2 + view.layer.cornerCurve = .continuous + view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + view.clipsToBounds = true + return view + }() + + let statusView = StatusView() + + override func prepareForReuse() { + super.prepareForReuse() + avatatImageView.af.cancelImageRequest() + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + statusView.playerContainerView.isHidden = true + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + override func layoutSubviews() { + super.layoutSubviews() + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } +} + +extension NotificationStatusTableViewCell { + func configure() { + selectionStyle = .none + + contentView.addSubview(avatatImageView) + avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) + avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) + + contentView.addSubview(actionImageBackground) + actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationStatusTableViewCell.actionImageBorderWidth, height: 24 + NotificationStatusTableViewCell.actionImageBorderWidth)) + actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) + + actionImageBackground.addSubview(actionImageView) + actionImageView.constrainToCenter() + + contentView.addSubview(nameLabel) + nameLabel.constrain([ + nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), + nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) + ]) + + contentView.addSubview(actionLabel) + actionLabel.constrain([ + actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), + actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), + contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) + ]) + + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + addStatusAndContainer() + } + + func addStatusAndContainer() { + contentView.addSubview(statusContainer) + statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) + + contentView.addSubview(statusView) + statusView.pin(top: 40 + 12, left: 63 + 12, bottom: 14 + 12, right: 14 + 12) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + } +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index d7decfddf..5124dab63 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -63,30 +63,9 @@ final class NotificationTableViewCell: UITableViewCell { return label }() - var nameLabelTop: NSLayoutConstraint! - var nameLabelBottom: NSLayoutConstraint! - - let statusContainer: UIView = { - let view = UIView() - view.backgroundColor = .clear - view.layer.cornerRadius = 6 - view.layer.borderWidth = 2 - view.layer.cornerCurve = .continuous - view.layer.borderColor = Asset.Colors.Border.notification.color.cgColor - view.clipsToBounds = true - return view - }() - - let statusView = StatusView() - override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() - statusView.pollTableView.dataSource = nil - statusView.playerContainerView.reset() - statusView.playerContainerView.isHidden = true disposeBag.removeAll() } @@ -99,13 +78,6 @@ final class NotificationTableViewCell: UITableViewCell { super.init(coder: coder) configure() } - - override func layoutSubviews() { - super.layoutSubviews() - DispatchQueue.main.async { - self.statusView.drawContentWarningImageView() - } - } } extension NotificationTableViewCell { @@ -122,12 +94,11 @@ extension NotificationTableViewCell { actionImageBackground.addSubview(actionImageView) actionImageView.constrainToCenter() - - nameLabelTop = nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24) - nameLabelBottom = contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24) + contentView.addSubview(nameLabel) nameLabel.constrain([ - nameLabelTop, + nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24), nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) ]) @@ -137,35 +108,11 @@ extension NotificationTableViewCell { actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) ]) - - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - } - public func nameLabelLayoutIn(center: Bool) { - if center { - nameLabelTop.constant = 24 - NSLayoutConstraint.activate([nameLabelBottom]) - statusView.removeFromSuperview() - statusContainer.removeFromSuperview() - } else { - nameLabelTop.constant = 12 - NSLayoutConstraint.deactivate([nameLabelBottom]) - addStatusAndContainer() - } - } - - func addStatusAndContainer() { - contentView.addSubview(statusContainer) - statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) - - contentView.addSubview(statusView) - statusView.pin(top: 40 + 12, left: 63 + 12, bottom: 14 + 12, right: 14 + 12) - } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor } } From ceba2772c1c04ff49af9529681a0ac11462acd2d Mon Sep 17 00:00:00 2001 From: ihugo Date: Wed, 14 Apr 2021 16:41:30 +0800 Subject: [PATCH 239/400] fix: remove magic numbers to make it clear and robust --- .../Settings/SettingsViewController.swift | 50 +++++++++++-------- .../Scene/Settings/SettingsViewModel.swift | 6 +-- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index b43ca30e3..4bdc115b2 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -301,33 +301,41 @@ extension SettingsViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard indexPath.section == 2 || indexPath.section == 3 else { return } + let snapshot = self.viewModel.dataSource.snapshot() + let sectionIds = snapshot.sectionIdentifiers + guard indexPath.section < sectionIds.count else { return } + let sectionIdentifier = sectionIds[indexPath.section] + let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) + guard indexPath.row < items.count else { return } + let item = items[indexPath.item] - if indexPath.section == 2 { + switch item { + case .boringZone: coordinator.present( scene: .safari(url: URL(string: "https://mastodon.online/terms")!), from: self, transition: .safariPresent(animated: true, completion: nil) ) - } - - // clear media cache - if indexPath.section == 3, indexPath.row == 0 { - // clean image cache for AlamofireImage - let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) - ImageDownloader.defaultURLCache().removeAllCachedResponses() - let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) - - // clean Kingfisher Cache - KingfisherManager.shared.cache.clearDiskCache() - } - - // logout - if indexPath.section == 3, indexPath.row == 1 { - alertToSignout() + case .spicyZone(let link): + // clear media cache + if link.title == L10n.Scene.Settings.Section.Spicyzone.clear { + // clean image cache for AlamofireImage + let diskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, diskBytes) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: clean image cache", ((#file as NSString).lastPathComponent), #line, #function) + ImageDownloader.defaultURLCache().removeAllCachedResponses() + let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) + + // clean Kingfisher Cache + KingfisherManager.shared.cache.clearDiskCache() + } + // logout + if link.title == L10n.Scene.Settings.Section.Spicyzone.signout { + alertToSignout() + } + default: + break } } } diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index ddee6c5f9..59219ab44 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -245,10 +245,10 @@ class SettingsViewModel: NSObject, NeedsDependency { L10n.Scene.Settings.Section.Spicyzone.signout] var spicyLinkItems = [SettingsItem]() for l in spicyLinks { - let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemRed)) + let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) spicyLinkItems.append(item) } - let spicySection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) + let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) snapshot.appendSections([spicySection]) snapshot.appendItems(spicyLinkItems) @@ -317,7 +317,7 @@ enum SettingsSection: Hashable { case apperance(title: String, selectedMode: SettingsItem) case notifications(title: String, items: [SettingsItem]) case boringZone(title: String, items: [SettingsItem]) - case spicyZone(tilte: String, items: [SettingsItem]) + case spicyZone(title: String, items: [SettingsItem]) var title: String { switch self { From 2d71a48e36f7ff18953198361f7fed1aeee968e3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 17:13:23 +0800 Subject: [PATCH 240/400] chore: fix order of notification and layout issue --- CoreDataStack/Entity/Notification.swift | 2 +- Mastodon/Diffiable/Section/NotificationSection.swift | 3 ++- Mastodon/Scene/Notification/NotificationViewController.swift | 5 ++--- .../TableViewCell/NotificationStatusTableViewCell.swift | 3 ++- Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift | 3 +++ Mastodon/Service/APIService/APIService+Notification.swift | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift index 144ad9c23..8a0595f6c 100644 --- a/CoreDataStack/Entity/Notification.swift +++ b/CoreDataStack/Entity/Notification.swift @@ -95,6 +95,6 @@ extension MastodonNotification { extension MastodonNotification: Managed { public static var defaultSortDescriptors: [NSSortDescriptor] { - return [NSSortDescriptor(keyPath: \MastodonNotification.updatedAt, ascending: false)] + return [NSSortDescriptor(keyPath: \MastodonNotification.createAt, ascending: false)] } } diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index de7f5d6c8..77e7ab5a5 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -70,9 +70,10 @@ extension NotificationSection { if let status = notification.status { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell cell.delegate = delegate + let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) NotificationSection.configure(cell: cell, dependency: dependency, - readableLayoutFrame: nil, + readableLayoutFrame: frame, timestampUpdatePublisher: timestampUpdatePublisher, status: status, requestUserID: requestUserID, diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index d047a0a5d..0935c6e81 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -35,6 +35,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) tableView.tableFooterView = UIView() + tableView.rowHeight = UITableView.automaticDimension return tableView }() @@ -135,9 +136,7 @@ extension NotificationViewController { // MARK: - UITableViewDelegate extension NotificationViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - return 68 - } + } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 1a0df283e..2b3b6972a 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -12,6 +12,7 @@ import UIKit final class NotificationStatusTableViewCell: UITableViewCell { static let actionImageBorderWidth: CGFloat = 2 + static let statusPadding: UIEdgeInsets = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) var disposeBag = Set() var delegate: NotificationTableViewCellDelegate? @@ -136,7 +137,7 @@ extension NotificationStatusTableViewCell { statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) contentView.addSubview(statusView) - statusView.pin(top: 40 + 12, left: 63 + 12, bottom: 14 + 12, right: 14 + 12) + statusView.pin(top: NotificationStatusTableViewCell.statusPadding.top, left: NotificationStatusTableViewCell.statusPadding.left, bottom: NotificationStatusTableViewCell.statusPadding.bottom, right: NotificationStatusTableViewCell.statusPadding.right) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift index 7ab18bb0c..923a06301 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift @@ -43,5 +43,8 @@ final class SearchBottomLoader: UITableViewCell { backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(activityIndicatorView) activityIndicatorView.constrainToCenter() + NSLayoutConstraint.activate([ + contentView.heightAnchor.constraint(equalToConstant: 44) + ]) } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index 84cb6d3c0..53595fa92 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -43,7 +43,7 @@ extension APIService { status = statusInCoreData } // use constrain to avoid repeated save - let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: Date())) + let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt)) os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.type, notification.account.username) } From f3394ff38243db28dccf6705e58c0ef490491557 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 17:37:58 +0800 Subject: [PATCH 241/400] feat: add navigation to Notification Cell --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Diffiable/Item/NotificationItem.swift | 2 +- .../Section/NotificationSection.swift | 27 +++++- Mastodon/Extension/UIView+Gesture.swift | 93 +++++++++++++++++++ .../NotificationViewController.swift | 29 +++++- .../NotificationViewModel+diffable.swift | 2 +- .../Notification/NotificationViewModel.swift | 4 +- .../NotificationStatusTableViewCell.swift | 2 +- .../NotificationTableViewCell.swift | 3 + 9 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 Mastodon/Extension/UIView+Gesture.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 150c29afb..f545b5138 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 2D206B8C25F6015000143C56 /* AudioPlaybackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */; }; 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; }; + 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; @@ -433,6 +434,7 @@ 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlaybackService.swift; sourceTree = ""; }; 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = ""; }; + 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -1615,6 +1617,7 @@ 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, + 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, @@ -2264,6 +2267,7 @@ 2D61335825C188A000CAE157 /* APIService+Persist+Status.swift in Sources */, 2D34D9DB261494120081BFC0 /* APIService+Search.swift in Sources */, DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, + 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index e4a53d2b1..6ef5b0c8b 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -10,7 +10,7 @@ import CoreData enum NotificationItem { - case notification(ObjectID: NSManagedObjectID) + case notification(objectID: NSManagedObjectID) case bottomLoader } diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 77e7ab5a5..74b5d6a40 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -92,7 +92,10 @@ extension NotificationSection { placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) - + cell.avatatImageView.gesture().sink { [weak cell] _ in + cell?.delegate?.userAvatarDidPressed(notification: notification) + } + .store(in: &cell.disposeBag) if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } @@ -115,7 +118,10 @@ extension NotificationSection { placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) ) - + cell.avatatImageView.gesture().sink { [weak cell] _ in + cell?.delegate?.userAvatarDidPressed(notification: notification) + } + .store(in: &cell.disposeBag) if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } @@ -399,9 +405,20 @@ extension NotificationSection { } } }() - - cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed - + if poll.expired { + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed + } else if let expiresAt = poll.expiresAt { + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + cell.pollCountdownSubscription = timestampUpdatePublisher + .sink { _ in + cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) + } + } else { + // assertionFailure() + cell.pollCountdownSubscription = nil + cell.statusView.pollCountdownLabel.text = "-" + } cell.statusView.pollTableView.allowsSelection = !poll.expired diff --git a/Mastodon/Extension/UIView+Gesture.swift b/Mastodon/Extension/UIView+Gesture.swift new file mode 100644 index 000000000..9c29c2c0d --- /dev/null +++ b/Mastodon/Extension/UIView+Gesture.swift @@ -0,0 +1,93 @@ +// +// UIView+Gesture.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import Combine +import Foundation +import UIKit + +struct GesturePublisher: Publisher { + typealias Output = GestureType + typealias Failure = Never + private let view: UIView + private let gestureType: GestureType + init(view: UIView, gestureType: GestureType) { + self.view = view + self.gestureType = gestureType + } + + func receive(subscriber: S) where S: Subscriber, + GesturePublisher.Failure == S.Failure, GesturePublisher.Output + == S.Input + { + let subscription = GestureSubscription( + subscriber: subscriber, + view: view, + gestureType: gestureType + ) + subscriber.receive(subscription: subscription) + } +} + +enum GestureType { + case tap(UITapGestureRecognizer = .init()) + case swipe(UISwipeGestureRecognizer = .init()) + case longPress(UILongPressGestureRecognizer = .init()) + case pan(UIPanGestureRecognizer = .init()) + case pinch(UIPinchGestureRecognizer = .init()) + case edge(UIScreenEdgePanGestureRecognizer = .init()) + func get() -> UIGestureRecognizer { + switch self { + case let .tap(tapGesture): + return tapGesture + case let .swipe(swipeGesture): + return swipeGesture + case let .longPress(longPressGesture): + return longPressGesture + case let .pan(panGesture): + return panGesture + case let .pinch(pinchGesture): + return pinchGesture + case let .edge(edgePanGesture): + return edgePanGesture + } + } +} + +class GestureSubscription: Subscription where S.Input == GestureType, S.Failure == Never { + private var subscriber: S? + private var gestureType: GestureType + private var view: UIView + init(subscriber: S, view: UIView, gestureType: GestureType) { + self.subscriber = subscriber + self.view = view + self.gestureType = gestureType + configureGesture(gestureType) + } + + private func configureGesture(_ gestureType: GestureType) { + let gesture = gestureType.get() + gesture.addTarget(self, action: #selector(handler)) + view.addGestureRecognizer(gesture) + } + + func request(_ demand: Subscribers.Demand) {} + func cancel() { + subscriber = nil + } + + @objc + private func handler() { + _ = subscriber?.receive(gestureType) + } +} + +extension UIView { + func gesture(_ gestureType: GestureType = .tap()) -> GesturePublisher { + self.isUserInteractionEnabled = true + return GesturePublisher(view: self, gestureType: gestureType) + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 0935c6e81..86c21a2c5 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -18,7 +18,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() - private(set) lazy var viewModel = NotificationViewModel(context: context, coordinator: coordinator) + private(set) lazy var viewModel = NotificationViewModel(context: context) let segmentControl: UISegmentedControl = { let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything,L10n.Scene.Notification.Title.mentions]) @@ -136,6 +136,24 @@ extension NotificationViewController { // MARK: - UITableViewDelegate extension NotificationViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .notification(let objectID): + let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification + if notification.status != nil { + // TODO goto status detail vc + } else { + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: notification.account) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + } + default: + break + } + } } @@ -147,9 +165,18 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl } extension NotificationViewController: NotificationTableViewCellDelegate { + func userAvatarDidPressed(notification: MastodonNotification) { + let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: notification.account) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + } + func parent() -> UIViewController { self } + + } //// MARK: - UIScrollViewDelegate diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index c9c0dcf6f..2d68586d8 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -71,7 +71,7 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) - newSnapshot.appendItems(notifications.map({NotificationItem.notification(ObjectID: $0.objectID)}), toSection: .main) + newSnapshot.appendItems(notifications.map({NotificationItem.notification(objectID: $0.objectID)}), toSection: .main) if !notifications.isEmpty { newSnapshot.appendItems([.bottomLoader], toSection: .main) } diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index b8d74152c..20bec7159 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -19,7 +19,6 @@ final class NotificationViewModel: NSObject { // input let context: AppContext - weak var coordinator: SceneCoordinator! weak var tableView: UITableView? weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? @@ -49,8 +48,7 @@ final class NotificationViewModel: NSObject { lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) - init(context: AppContext,coordinator: SceneCoordinator) { - self.coordinator = coordinator + init(context: AppContext) { self.context = context self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) self.fetchedResultsController = { diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 2b3b6972a..4731b4e1b 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -14,7 +14,7 @@ final class NotificationStatusTableViewCell: UITableViewCell { static let statusPadding: UIEdgeInsets = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) var disposeBag = Set() - + var pollCountdownSubscription: AnyCancellable? var delegate: NotificationTableViewCellDelegate? let avatatImageView: UIImageView = { diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 5124dab63..1068e7732 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -8,11 +8,14 @@ import Combine import Foundation import UIKit +import CoreDataStack protocol NotificationTableViewCellDelegate: class { var context: AppContext! { get } func parent() -> UIViewController + + func userAvatarDidPressed(notification:MastodonNotification) } final class NotificationTableViewCell: UITableViewCell { From bffb0a887b2b6a77752601676aeb36d62ecd9a3f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 18:00:43 +0800 Subject: [PATCH 242/400] chore: rename searchBottomLoader , rename pure color --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- Mastodon/Diffiable/Section/NotificationSection.swift | 7 +++++-- Mastodon/Diffiable/Section/SearchResultSection.swift | 2 +- Mastodon/Generated/Assets.swift | 2 +- .../Contents.json | 0 .../Scene/Notification/NotificationViewController.swift | 4 ++-- .../TableViewCell/NotificationStatusTableViewCell.swift | 8 ++++---- .../TableViewCell/NotificationTableViewCell.swift | 8 ++++---- .../Scene/Search/SearchViewController+Searching.swift | 2 +- Mastodon/Scene/Search/SearchViewController.swift | 4 ++-- ...{SearchBottomLoader.swift => CommonBottomLoader.swift} | 4 ++-- 11 files changed, 26 insertions(+), 23 deletions(-) rename Mastodon/Resources/Assets.xcassets/Colors/Background/{searchResult.colorset => pure.colorset}/Contents.json (100%) rename Mastodon/Scene/Search/TableViewCell/{SearchBottomLoader.swift => CommonBottomLoader.swift} (94%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f545b5138..14de9fc4b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -37,7 +37,7 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; - 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */; }; + 2D19864F261C372A00F0B013 /* CommonBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */; }; 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; @@ -426,7 +426,7 @@ 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; - 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBottomLoader.swift; sourceTree = ""; }; + 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonBottomLoader.swift; sourceTree = ""; }; 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; @@ -1152,7 +1152,7 @@ isa = PBXGroup; children = ( 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, - 2D19864E261C372A00F0B013 /* SearchBottomLoader.swift */, + 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */, ); path = TableViewCell; sourceTree = ""; @@ -2269,7 +2269,7 @@ DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, - 2D19864F261C372A00F0B013 /* SearchBottomLoader.swift in Sources */, + 2D19864F261C372A00F0B013 /* CommonBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 74b5d6a40..01273e9f4 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -128,7 +128,7 @@ extension NotificationSection { return cell } case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: CommonBottomLoader.self)) as! CommonBottomLoader cell.startAnimating() return cell } @@ -149,8 +149,11 @@ extension NotificationSection { ) { // disable interaction cell.statusView.isUserInteractionEnabled = false - // remove actionToolBar + // remove item don't display cell.statusView.actionToolbarContainer.removeFromSuperview() + cell.statusView.avatarView.removeFromSuperview() + + // setup attribute statusItemAttribute.setupForStatus(status: status) diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 50c561605..e01063c86 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -44,7 +44,7 @@ extension SearchResultSection { cell.config(with: user) return cell case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: CommonBottomLoader.self)) as! CommonBottomLoader cell.startAnimating() return cell } diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index a9963ce9c..40eda8019 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,7 +44,7 @@ internal enum Asset { internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") - internal static let searchResult = ColorAsset(name: "Colors/Background/searchResult") + internal static let pure = ColorAsset(name: "Colors/Background/pure") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 86c21a2c5..8d04674f4 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -33,7 +33,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) - tableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) + tableView.register(CommonBottomLoader.self, forCellReuseIdentifier: String(describing: CommonBottomLoader.self)) tableView.tableFooterView = UIView() tableView.rowHeight = UITableView.automaticDimension return tableView @@ -47,7 +47,7 @@ extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.searchResult.color + view.backgroundColor = Asset.Colors.Background.pure.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) view.addSubview(tableView) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 4731b4e1b..37b745990 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -27,7 +27,7 @@ final class NotificationStatusTableViewCell: UITableViewCell { let actionImageView: UIImageView = { let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Background.searchResult.color + imageView.tintColor = Asset.Colors.Background.pure.color return imageView }() @@ -37,8 +37,8 @@ final class NotificationStatusTableViewCell: UITableViewCell { view.layer.cornerCurve = .continuous view.clipsToBounds = true view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth - view.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor - view.tintColor = Asset.Colors.Background.searchResult.color + view.layer.borderColor = Asset.Colors.Background.pure.color.cgColor + view.tintColor = Asset.Colors.Background.pure.color return view }() @@ -143,6 +143,6 @@ extension NotificationStatusTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor - actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor } } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 1068e7732..a10ff01d6 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -35,7 +35,7 @@ final class NotificationTableViewCell: UITableViewCell { let actionImageView: UIImageView = { let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Background.searchResult.color + imageView.tintColor = Asset.Colors.Background.pure.color return imageView }() @@ -45,8 +45,8 @@ final class NotificationTableViewCell: UITableViewCell { view.layer.cornerCurve = .continuous view.clipsToBounds = true view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth - view.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor - view.tintColor = Asset.Colors.Background.searchResult.color + view.layer.borderColor = Asset.Colors.Background.pure.color.cgColor + view.tintColor = Asset.Colors.Background.pure.color return view }() @@ -116,6 +116,6 @@ extension NotificationTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - actionImageBackground.layer.borderColor = Asset.Colors.Background.searchResult.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor } } diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 3eb9793ad..43e5d397c 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -17,7 +17,7 @@ extension SearchViewController { func setupSearchingTableView() { searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) - searchingTableView.register(SearchBottomLoader.self, forCellReuseIdentifier: String(describing: SearchBottomLoader.self)) + searchingTableView.register(CommonBottomLoader.self, forCellReuseIdentifier: String(describing: CommonBottomLoader.self)) view.addSubview(searchingTableView) searchingTableView.constrain([ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 5dcc47e02..dc9414585 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.searchResult.color + tableView.backgroundColor = Asset.Colors.Background.pure.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) @@ -227,7 +227,7 @@ extension SearchViewController: UISearchBarDelegate { } extension SearchViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = SearchBottomLoader + typealias BottomLoaderTableViewCell = CommonBottomLoader typealias LoadingState = SearchViewModel.LoadOldestState.Loading var loadMoreConfigurableTableView: UITableView { searchingTableView } var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } diff --git a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift similarity index 94% rename from Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift rename to Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift index 923a06301..bb2ae9ce0 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchBottomLoader.swift +++ b/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift @@ -1,5 +1,5 @@ // -// SearchBottomLoader.swift +// CommonBottomLoader.swift // Mastodon // // Created by sxiaojian on 2021/4/6. @@ -8,7 +8,7 @@ import Foundation import UIKit -final class SearchBottomLoader: UITableViewCell { +final class CommonBottomLoader: UITableViewCell { let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) activityIndicatorView.tintColor = Asset.Colors.Label.primary.color From 6c973ed17c745072736924ee1d3f0acaa3cb7399 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 18:11:59 +0800 Subject: [PATCH 243/400] fix: resolve #83 the text editor input content offset reset after input character issue --- Mastodon.xcodeproj/project.pbxproj | 8 +-- .../Section/ComposeStatusSection.swift | 6 ++ ...eStatusAttachmentCollectionViewCell.swift} | 2 +- ...mposeStatusContentCollectionViewCell.swift | 2 +- .../Scene/Compose/ComposeViewController.swift | 55 ++++++++++++------- 5 files changed, 48 insertions(+), 25 deletions(-) rename Mastodon/Scene/Compose/CollectionViewCell/{ComposeStatusAttachmentTableViewCell.swift => ComposeStatusAttachmentCollectionViewCell.swift} (98%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ea937b52d..f6bf54a2b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -220,7 +220,7 @@ DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A06225E905E000CFDF14 /* UIApplication.swift */; }; DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; - DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */; }; + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; @@ -618,7 +618,7 @@ DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; DB68A06225E905E000CFDF14 /* UIApplication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = ""; }; DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; - DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentTableViewCell.swift; sourceTree = ""; }; + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; @@ -1458,7 +1458,7 @@ children = ( DB789A2A25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift */, DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */, - DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift */, + DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */, DB87D4442609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift */, DB87D4502609CF1E00D12C0D /* ComposeStatusPollOptionAppendEntryCollectionViewCell.swift */, DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */, @@ -2296,7 +2296,7 @@ 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, - DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentTableViewCell.swift in Sources */, + DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 4b0b5aa57..49f4fb5b7 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -92,7 +92,12 @@ extension ComposeStatusSection { .receive(on: DispatchQueue.main) .sink { text in // self size input cell + // needs restore content offset to resolve issue #83 + let oldContentOffset = collectionView.contentOffset collectionView.collectionViewLayout.invalidateLayout() + collectionView.layoutIfNeeded() + collectionView.contentOffset = oldContentOffset + // bind input data attribute.composeContent.value = text } @@ -187,6 +192,7 @@ extension ComposeStatusSection { case .pollOption(let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self), for: indexPath) as! ComposeStatusPollOptionCollectionViewCell cell.pollOptionView.optionTextField.text = attribute.option.value + cell.pollOptionView.optionTextField.placeholder = L10n.Scene.Compose.Poll.optionNumber(indexPath.item + 1) cell.pollOption .receive(on: DispatchQueue.main) .assign(to: \.value, on: attribute.option) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift similarity index 98% rename from Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift rename to Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift index bc087c990..141a944fd 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentTableViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift @@ -1,5 +1,5 @@ // -// ComposeStatusAttachmentTableViewCell.swift +// ComposeStatusAttachmentCollectionViewCell.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-17. diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index f1fe6b541..2b71e55f3 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -91,7 +91,7 @@ extension ComposeStatusContentCollectionViewCell { statusContentWarningEditorView.containerView.isHidden = true } - + } // MARK: - TextEditorViewChangeObserver diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 29b8850b9..e56caf81f 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -68,18 +68,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { let composeToolbarView = ComposeToolbarView() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! - let composeToolbarBackgroundView: UIView = { - let backgroundView = UIView() - // set keyboard background to make the keyboard blurred color fixed - backgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in - // avoid elevated color - switch traitCollection.userInterfaceStyle { - case .light: return .white - default: return .black - } - }) - return backgroundView - }() + let composeToolbarBackgroundView = UIView() private(set) lazy var imagePicker: PHPickerViewController = { var configuration = PHPickerConfiguration() @@ -202,14 +191,27 @@ extension ComposeViewController { ) .sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in guard let self = self else { return } + + let extraMargin: CGFloat = { + if self.view.safeAreaInsets.bottom == .zero { + // needs extra margin for zero inset device to workaround UIKit issue + return self.composeToolbarView.frame.height + } else { + // default some magic 16 extra margin + return 16 + } + }() + + // update keyboard background color guard isShow, state == .dock else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } + self.updateKeyboardBackground(isKeyboardDisplay: isShow) return } // isShow AND dock state @@ -218,22 +220,23 @@ extension ComposeViewController { let contentFrame = self.view.convert(self.collectionView.frame, to: nil) let padding = contentFrame.maxY - endFrame.minY guard padding > 0 else { - self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom - self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom self.view.layoutIfNeeded() } + self.updateKeyboardBackground(isKeyboardDisplay: false) return } - // add 16pt margin - self.collectionView.contentInset.bottom = padding + 16 - self.collectionView.verticalScrollIndicatorInsets.bottom = padding + 16 + self.collectionView.contentInset.bottom = padding + extraMargin + self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = padding self.view.layoutIfNeeded() } + self.updateKeyboardBackground(isKeyboardDisplay: isShow) }) .store(in: &disposeBag) @@ -473,6 +476,20 @@ extension ComposeViewController { imagePicker.delegate = self return imagePicker } + + private func updateKeyboardBackground(isKeyboardDisplay: Bool) { + guard isKeyboardDisplay else { + composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color + return + } + composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in + // avoid elevated color + switch traitCollection.userInterfaceStyle { + case .light: return .white + default: return .black + } + }) + } } From 687614d43a93708064b9f05faa57d5a9503f5c1a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 19:04:11 +0800 Subject: [PATCH 244/400] feat: add bottom loader --- Mastodon.xcodeproj/project.pbxproj | 4 + .../NotificationViewController.swift | 28 ++-- ...otificationViewModel+LoadOldestState.swift | 128 ++++++++++++++++++ .../Notification/NotificationViewModel.swift | 16 +++ 4 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 14de9fc4b..d088d3176 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B9125F60EA700143C56 /* UIControl.swift */; }; 2D24E11D2626D8B100A59D4F /* NotificationStatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */; }; 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */; }; + 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */; }; 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */; }; 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAB925CB9B0500C9ED86 /* UIView.swift */; }; 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */; }; @@ -435,6 +436,7 @@ 2D206B9125F60EA700143C56 /* UIControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIControl.swift; sourceTree = ""; }; 2D24E11C2626D8B100A59D4F /* NotificationStatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusTableViewCell.swift; sourceTree = ""; }; 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Gesture.swift"; sourceTree = ""; }; + 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D32EAAB25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMiddleLoaderTableViewCell.swift; sourceTree = ""; }; 2D32EAB925CB9B0500C9ED86 /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = ""; }; 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -1679,6 +1681,7 @@ 2D607AD726242FC500B70763 /* NotificationViewModel.swift */, 2D084B8C26258EA3003AA3AF /* NotificationViewModel+diffable.swift */, 2D084B9226259545003AA3AF /* NotificationViewModel+LoadLatestState.swift */, + 2D24E12C2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift */, 2D35237F26256F470031AF25 /* TableViewCell */, ); path = Notification; @@ -2401,6 +2404,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, + 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 8d04674f4..d5eb149a8 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -11,6 +11,7 @@ import OSLog import CoreData import CoreDataStack import MastodonSDK +import GameplayKit final class NotificationViewController: UIViewController, NeedsDependency { @@ -123,6 +124,7 @@ extension NotificationViewController { } else { viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, type: Mastodon.Entity.Notification.NotificationType.mention.rawValue) } + viewModel.selectedIndex.value = sender.selectedSegmentIndex } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -179,16 +181,16 @@ extension NotificationViewController: NotificationTableViewCellDelegate { } -//// MARK: - UIScrollViewDelegate -//extension NotificationViewController { -// func scrollViewDidScroll(_ scrollView: UIScrollView) { -// handleScrollViewDidScroll(scrollView) -// } -//} -// -//extension NotificationViewController: LoadMoreConfigurableTableViewContainer { -// typealias BottomLoaderTableViewCell = SearchBottomLoader -// typealias LoadingState = NotificationViewController.LoadOldestState.Loading -// var loadMoreConfigurableTableView: UITableView { return tableView } -// var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } -//} +// MARK: - UIScrollViewDelegate +extension NotificationViewController { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + handleScrollViewDidScroll(scrollView) + } +} + +extension NotificationViewController: LoadMoreConfigurableTableViewContainer { + typealias BottomLoaderTableViewCell = CommonBottomLoader + typealias LoadingState = NotificationViewModel.LoadOldestState.Loading + var loadMoreConfigurableTableView: UITableView { return tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } +} diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift new file mode 100644 index 000000000..13c82093b --- /dev/null +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -0,0 +1,128 @@ +// +// NotificationViewModel+LoadOldestState.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/14. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension NotificationViewModel { + class LoadOldestState: GKState { + weak var viewModel: NotificationViewModel? + + init(viewModel: NotificationViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + viewModel?.loadOldestStateMachinePublisher.send(self) + } + } +} + +extension NotificationViewModel.LoadOldestState { + class Initial: NotificationViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let viewModel = viewModel else { return false } + guard !(viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty else { return false } + return stateClass == Loading.self + } + } + + class Loading: NotificationViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + assertionFailure() + stateMachine.enter(Fail.self) + return + } + + guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + stateMachine.enter(Idle.self) + return + } + + let maxID = last.id + let query = Mastodon.API.Notifications.Query( + maxID: maxID, + sinceID: nil, + minID: nil, + limit: nil, + excludeTypes: Mastodon.API.Notifications.allExcludeTypes(), + accountID: nil) + viewModel.context.apiService.allNotifications( + domain: activeMastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + + stateMachine.enter(Idle.self) + } receiveValue: { [weak viewModel] response in + guard let viewModel = viewModel else { return } + if viewModel.selectedIndex.value == 1 { + let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } + if list.isEmpty { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + } else { + if response.value.isEmpty { + stateMachine.enter(NoMore.self) + } else { + stateMachine.enter(Idle.self) + } + } + } + .store(in: &viewModel.disposeBag) + } + } + + class Fail: NotificationViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self || stateClass == Idle.self + } + } + + class Idle: NotificationViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Loading.self + } + } + + class NoMore: NotificationViewModel.LoadOldestState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // reset state if needs + return stateClass == Idle.self + } + + override func didEnter(from previousState: GKState?) { + guard let viewModel = viewModel else { return } + guard let diffableDataSource = viewModel.diffableDataSource else { + assertionFailure() + return + } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 20bec7159..26d21e796 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -23,6 +23,7 @@ final class NotificationViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? let viewDidLoad = PassthroughSubject() + let selectedIndex = CurrentValueSubject(0) let activeMastodonAuthenticationBox: CurrentValueSubject let fetchedResultsController: NSFetchedResultsController! @@ -48,6 +49,21 @@ final class NotificationViewModel: NSObject { lazy var loadLatestStateMachinePublisher = CurrentValueSubject(nil) + // bottom loader + private(set) lazy var loadoldestStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + LoadOldestState.Initial(viewModel: self), + LoadOldestState.Loading(viewModel: self), + LoadOldestState.Fail(viewModel: self), + LoadOldestState.Idle(viewModel: self), + LoadOldestState.NoMore(viewModel: self), + ]) + stateMachine.enter(LoadOldestState.Initial.self) + return stateMachine + }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) + init(context: AppContext) { self.context = context self.activeMastodonAuthenticationBox = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value) From 1723891099a0610c69efb092ee1638e4116af870 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 19:45:09 +0800 Subject: [PATCH 245/400] chore: update background dark mode to follow system naming --- Mastodon/Generated/Assets.swift | 1 - .../searchResult.colorset/Contents.json | 38 ------------------- .../Contents.json | 6 +-- .../Contents.json | 6 +-- .../system.background.colorset/Contents.json | 6 +-- .../Contents.json | 6 +-- .../Contents.json | 6 +-- .../Compose/background.colorset/Contents.json | 6 +-- ...iedToStatusContentCollectionViewCell.swift | 2 +- ...lOptionAppendEntryCollectionViewCell.swift | 4 +- .../Scene/Compose/ComposeViewController.swift | 2 +- .../HashtagTimelineViewController.swift | 2 +- .../HomeTimelineViewController.swift | 3 +- .../Favorite/FavoriteViewController.swift | 2 +- .../Timeline/UserTimelineViewController.swift | 2 +- .../Scene/Search/SearchViewController.swift | 2 +- .../Share/View/Content/PollOptionView.swift | 3 +- .../Scene/Share/View/Content/StatusView.swift | 2 +- .../TableviewCell/StatusTableViewCell.swift | 2 +- .../TimelineLoaderTableViewCell.swift | 2 +- 20 files changed, 32 insertions(+), 71 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 52efbda77..0f20937fd 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,7 +44,6 @@ internal enum Asset { internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") - internal static let searchResult = ColorAsset(name: "Colors/Background/searchResult") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json deleted file mode 100644 index 3338422aa..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/searchResult.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xFE", - "green" : "0xFF", - "red" : "0xFE" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index 3338422aa..55f84c267 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index 6bce2b697..bd6f07f25 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x00", - "green" : "0x00", - "red" : "0x00" + "blue" : "0x2E", + "green" : "0x2C", + "red" : "0x2C" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json index 6b372a191..23d03492f 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json index 6d9833e91..6bce2b697 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.grouped.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "46", - "green" : "44", - "red" : "44" + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json index 5da572b1d..9fa2b261b 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/tertiary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE1", - "red" : "0xD9" + "blue" : "0xFE", + "green" : "0xFF", + "red" : "0xFE" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json index e13fb4690..82edd034b 100644 --- a/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Scene/Compose/background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x1E", - "green" : "0x1C", - "red" : "0x1C" + "blue" : "30", + "green" : "28", + "red" : "28" } }, "idiom" : "universal" diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 51c6f2fd3..35af3c0ac 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -38,7 +38,7 @@ extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { backgroundColor = .clear - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Scene.Compose.background.color + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift index 2c321f51f..39a12f954 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -29,7 +29,7 @@ final class ComposeStatusPollOptionAppendEntryCollectionViewCell: UICollectionVi override var isHighlighted: Bool { didSet { - pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.secondarySystemBackground.color : Asset.Colors.Background.systemBackground.color + pollOptionView.roundedBackgroundView.backgroundColor = isHighlighted ? Asset.Colors.Background.tertiarySystemBackground.color : Asset.Colors.Background.secondarySystemBackground.color pollOptionView.plusCircleImageView.tintColor = isHighlighted ? Asset.Colors.Button.normal.color.withAlphaComponent(0.5) : Asset.Colors.Button.normal.color } } @@ -82,7 +82,7 @@ extension ComposeStatusPollOptionAppendEntryCollectionViewCell { pollOptionView.optionTextField.isHidden = true pollOptionView.plusCircleImageView.isHidden = false - pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemBackground.color + pollOptionView.roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color setupBorderColor() pollOptionView.addGestureRecognizer(singleTagGestureRecognizer) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index e56caf81f..636d2de63 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -49,7 +49,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self)) collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) - collectionView.backgroundColor = Asset.Colors.Background.systemBackground.color + collectionView.backgroundColor = Asset.Scene.Compose.background.color return collectionView }() diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 4b45d6638..ea1a03aa9 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -57,7 +57,7 @@ extension HashtagTimelineViewController { titleView.update(title: viewModel.hashtag, subtitle: nil) navigationItem.titleView = titleView - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.rightBarButtonItem = composeBarButtonItem diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index f4248ad34..53909b2df 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -47,7 +47,6 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear - return tableView }() @@ -71,7 +70,7 @@ extension HomeTimelineViewController { super.viewDidLoad() title = L10n.Scene.HomeTimeline.title - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.leftBarButtonItem = settingBarButtonItem navigationItem.titleView = titleView titleView.delegate = self diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 8205d5a2e..1e10a6322 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -45,7 +45,7 @@ extension FavoriteViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = titleView titleView.update(title: L10n.Scene.Favorite.title, subtitle: nil) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 88a98b589..2ec350b07 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -45,7 +45,7 @@ extension UserTimelineViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 5dcc47e02..704e425a6 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.searchResult.color + tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift index eafeb55cf..7125b691c 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -27,7 +27,7 @@ final class PollOptionView: UIView { let checkmarkBackgroundView: UIView = { let view = UIView() - view.backgroundColor = .systemBackground + view.backgroundColor = Asset.Colors.Background.tertiarySystemBackground.color return view }() @@ -81,6 +81,7 @@ final class PollOptionView: UIView { extension PollOptionView { private func _init() { + // default color in the timeline roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 63e8bb8f1..9365179ef 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -181,7 +181,7 @@ final class StatusView: UIView { // do not use visual effect view due to we blur text only without background let contentWarningBlurContentImageView: UIImageView = { let imageView = UIImageView() - imageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + imageView.backgroundColor = Asset.Colors.Background.systemBackground.color imageView.layer.masksToBounds = false return imageView }() diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 10fcdca3c..9887eaa9f 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -102,7 +102,7 @@ final class StatusTableViewCell: UITableViewCell { extension StatusTableViewCell { private func _init() { - backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + backgroundColor = Asset.Colors.Background.systemBackground.color statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index dc144d109..749f2eb51 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -73,7 +73,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { func _init() { selectionStyle = .none - backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + backgroundColor = .clear loadMoreButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(loadMoreButton) From aa3dff077059386a90c9c12dbc765a37f61f16ec Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 14 Apr 2021 19:49:50 +0800 Subject: [PATCH 246/400] feat: update thread dark mode background color --- Mastodon/Scene/Thread/ThreadViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index db7c76a75..bd15b930e 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -50,7 +50,7 @@ extension ThreadViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.title = L10n.Scene.Thread.backTitle navigationItem.titleView = titleView navigationItem.rightBarButtonItem = replyBarButtonItem From 2e86449c41dd40d67c6b86ca72d48a190ed4525f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 14 Apr 2021 20:02:41 +0800 Subject: [PATCH 247/400] fix: bottom loader display aways --- .../Section/NotificationSection.swift | 8 +------- .../NotificationViewController.swift | 13 +++++++++++++ ...otificationViewModel+LoadOldestState.swift | 19 ++++++++++++++++--- .../NotificationViewModel+diffable.swift | 5 ++++- .../Notification/NotificationViewModel.swift | 1 + .../NotificationStatusTableViewCell.swift | 7 +++++++ 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 01273e9f4..9fbad362b 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -147,13 +147,7 @@ extension NotificationSection { requestUserID: String, statusItemAttribute: Item.StatusAttribute ) { - // disable interaction - cell.statusView.isUserInteractionEnabled = false - // remove item don't display - cell.statusView.actionToolbarContainer.removeFromSuperview() - cell.statusView.avatarView.removeFromSuperview() - - + // setup attribute statusItemAttribute.setupForStatus(status: status) diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index d5eb149a8..a1921558c 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -156,6 +156,19 @@ extension NotificationViewController: UITableViewDelegate { break } } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .bottomLoader: + if !tableView.isDragging && !tableView.isDecelerating { + viewModel.loadoldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self) + } + default: + break + } + } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift index 13c82093b..bf049eace 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -9,6 +9,7 @@ import os.log import Foundation import GameplayKit import MastodonSDK +import CoreDataStack extension NotificationViewModel { class LoadOldestState: GKState { @@ -42,13 +43,24 @@ extension NotificationViewModel.LoadOldestState { override func didEnter(from previousState: GKState?) { super.didEnter(from: previousState) guard let viewModel = viewModel, let stateMachine = stateMachine else { return } - guard let activeMastodonAuthenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + guard let activeMastodonAuthenticationBox = viewModel.activeMastodonAuthenticationBox.value else { assertionFailure() stateMachine.enter(Fail.self) return } - - guard let last = viewModel.fetchedResultsController.fetchedObjects?.last else { + let notifications: [MastodonNotification]? = { + let request = MastodonNotification.sortedFetchRequest + request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain) + request.returnsObjectsAsFaults = false + do { + return try self.viewModel?.context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + guard let last = notifications?.last else { stateMachine.enter(Idle.self) return } @@ -78,6 +90,7 @@ extension NotificationViewModel.LoadOldestState { } receiveValue: { [weak viewModel] response in guard let viewModel = viewModel else { return } if viewModel.selectedIndex.value == 1 { + viewModel.noMoreNotification.value = response.value.isEmpty let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } if list.isEmpty { stateMachine.enter(NoMore.self) diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index 2d68586d8..a4c86521f 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -72,7 +72,7 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) newSnapshot.appendItems(notifications.map({NotificationItem.notification(objectID: $0.objectID)}), toSection: .main) - if !notifications.isEmpty { + if !notifications.isEmpty && self.noMoreNotification.value == false { newSnapshot.appendItems([.bottomLoader], toSection: .main) } @@ -112,6 +112,9 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { guard sourceIndexPath.row < oldSnapshot.itemIdentifiers(inSection: .main).count else { return nil } + if oldSnapshot.itemIdentifiers.elementsEqual(newSnapshot.itemIdentifiers) { + return nil + } let timelineItem = oldSnapshot.itemIdentifiers(inSection: .main)[sourceIndexPath.row] guard let itemIndex = newSnapshot.itemIdentifiers(inSection: .main).firstIndex(of: timelineItem) else { return nil } let targetIndexPath = IndexPath(row: itemIndex, section: 0) diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 26d21e796..85806acf2 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -24,6 +24,7 @@ final class NotificationViewModel: NSObject { let viewDidLoad = PassthroughSubject() let selectedIndex = CurrentValueSubject(0) + let noMoreNotification = CurrentValueSubject(false) let activeMastodonAuthenticationBox: CurrentValueSubject let fetchedResultsController: NSFetchedResultsController! diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 37b745990..6bdf0fd1e 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -79,6 +79,7 @@ final class NotificationStatusTableViewCell: UITableViewCell { statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true + disposeBag.removeAll() } @@ -133,6 +134,12 @@ extension NotificationStatusTableViewCell { } func addStatusAndContainer() { + statusView.isUserInteractionEnabled = false + // remove item don't display + statusView.actionToolbarContainer.removeFromSuperview() + statusView.avatarView.removeFromSuperview() + statusView.usernameLabel.removeFromSuperview() + contentView.addSubview(statusContainer) statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) From 80d76acc27ef31dbfe94da47c18af2f8a236e472 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 15 Apr 2021 10:16:30 +0800 Subject: [PATCH 248/400] chore: format file --- .../Diffiable/Item/NotificationItem.swift | 5 ++- .../Section/NotificationSection.swift | 13 +++---- Mastodon/Extension/UIView+Gesture.swift | 2 +- .../NotificationViewController.swift | 36 ++++++++----------- ...otificationViewModel+LoadLatestState.swift | 21 +++++------ ...otificationViewModel+LoadOldestState.swift | 16 ++++----- .../NotificationViewModel+diffable.swift | 17 ++++----- .../Notification/NotificationViewModel.swift | 23 ++++++------ .../NotificationStatusTableViewCell.swift | 2 +- .../NotificationTableViewCell.swift | 7 ++-- .../APIService/APIService+Notification.swift | 11 +++--- 11 files changed, 66 insertions(+), 87 deletions(-) diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index 6ef5b0c8b..c160eac5e 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -5,11 +5,10 @@ // Created by sxiaojian on 2021/4/13. // -import Foundation import CoreData +import Foundation enum NotificationItem { - case notification(objectID: NSManagedObjectID) case bottomLoader @@ -20,7 +19,7 @@ extension NotificationItem: Equatable { switch (lhs, rhs) { case (.bottomLoader, .bottomLoader): return true - case (.notification(let idLeft),.notification(let idRight)): + case (.notification(let idLeft), .notification(let idRight)): return idLeft == idRight default: return false diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 9fbad362b..5e3cd2d9e 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -5,12 +5,12 @@ // Created by sxiaojian on 2021/4/13. // +import Combine import CoreData import CoreDataStack import Foundation import MastodonSDK import UIKit -import Combine enum NotificationSection: Equatable, Hashable { case main @@ -25,8 +25,8 @@ extension NotificationSection { dependency: NeedsDependency, requestUserID: String ) -> UITableViewDiffableDataSource { - return UITableViewDiffableDataSource(tableView: tableView) { - [weak delegate,weak dependency] + UITableViewDiffableDataSource(tableView: tableView) { + [weak delegate, weak dependency] (tableView, indexPath, notificationItem) -> UITableViewCell? in guard let dependency = dependency else { return nil } switch notificationItem { @@ -136,7 +136,6 @@ extension NotificationSection { } } - extension NotificationSection { static func configure( cell: NotificationStatusTableViewCell, @@ -147,7 +146,6 @@ extension NotificationSection { requestUserID: String, statusItemAttribute: Item.StatusAttribute ) { - // setup attribute statusItemAttribute.setupForStatus(status: status) @@ -176,7 +174,6 @@ extension NotificationSection { cell.statusView.avatarStackedContainerButton.isHidden = true cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - // set text cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) @@ -338,7 +335,6 @@ extension NotificationSection { cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow } .store(in: &cell.disposeBag) - } static func configureHeader( @@ -366,7 +362,6 @@ extension NotificationSection { } } - static func configurePoll( cell: NotificationStatusTableViewCell, poll: Poll?, @@ -486,7 +481,7 @@ extension NotificationSection { } } -extension NotificationSection { +extension NotificationSection { private static func formattedNumberTitleForActionButton(_ number: Int?) -> String { guard let number = number, number > 0 else { return "" } return String(number) diff --git a/Mastodon/Extension/UIView+Gesture.swift b/Mastodon/Extension/UIView+Gesture.swift index 9c29c2c0d..a76843d89 100644 --- a/Mastodon/Extension/UIView+Gesture.swift +++ b/Mastodon/Extension/UIView+Gesture.swift @@ -87,7 +87,7 @@ class GestureSubscription: Subscription where S.Input == GestureT extension UIView { func gesture(_ gestureType: GestureType = .tap()) -> GesturePublisher { - self.isUserInteractionEnabled = true + isUserInteractionEnabled = true return GesturePublisher(view: self, gestureType: gestureType) } } diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index a1921558c..becf86771 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -5,16 +5,15 @@ // Created by sxiaojian on 2021/4/12. // -import UIKit import Combine -import OSLog import CoreData import CoreDataStack -import MastodonSDK import GameplayKit +import MastodonSDK +import OSLog +import UIKit final class NotificationViewController: UIViewController, NeedsDependency { - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -22,7 +21,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { private(set) lazy var viewModel = NotificationViewModel(context: context) let segmentControl: UISegmentedControl = { - let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything,L10n.Scene.Notification.Title.mentions]) + let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) control.selectedSegmentIndex = 0 return control }() @@ -41,11 +40,9 @@ final class NotificationViewController: UIViewController, NeedsDependency { }() let refreshControl = UIRefreshControl() - } extension NotificationViewController { - override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = Asset.Colors.Background.pure.color @@ -80,7 +77,6 @@ extension NotificationViewController { } } .store(in: &disposeBag) - } override func viewWillAppear(_ animated: Bool) { @@ -110,12 +106,11 @@ extension NotificationViewController { self.tableView.reloadData() } } - } extension NotificationViewController { @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) { - os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, sender.selectedSegmentIndex) + os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", (#file as NSString).lastPathComponent, #line, #function, sender.selectedSegmentIndex) guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain else { return } @@ -136,8 +131,8 @@ extension NotificationViewController { } // MARK: - UITableViewDelegate + extension NotificationViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } @@ -145,9 +140,9 @@ extension NotificationViewController: UITableViewDelegate { case .notification(let objectID): let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification if notification.status != nil { - // TODO goto status detail vc + // TODO: goto status detail vc } else { - let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: notification.account) + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) DispatchQueue.main.async { self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) } @@ -162,26 +157,26 @@ extension NotificationViewController: UITableViewDelegate { guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { case .bottomLoader: - if !tableView.isDragging && !tableView.isDecelerating { + if !tableView.isDragging, !tableView.isDecelerating { viewModel.loadoldestStateMachine.enter(NotificationViewModel.LoadOldestState.Loading.self) } default: break } } - } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate + extension NotificationViewController: ContentOffsetAdjustableTimelineViewControllerDelegate { func navigationBar() -> UINavigationBar? { - return navigationController?.navigationBar + navigationController?.navigationBar } } extension NotificationViewController: NotificationTableViewCellDelegate { func userAvatarDidPressed(notification: MastodonNotification) { - let viewModel = ProfileViewModel(context: self.context, optionalMastodonUser: notification.account) + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) DispatchQueue.main.async { self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) } @@ -190,11 +185,10 @@ extension NotificationViewController: NotificationTableViewCellDelegate { func parent() -> UIViewController { self } - - } // MARK: - UIScrollViewDelegate + extension NotificationViewController { func scrollViewDidScroll(_ scrollView: UIScrollView) { handleScrollViewDidScroll(scrollView) @@ -204,6 +198,6 @@ extension NotificationViewController { extension NotificationViewController: LoadMoreConfigurableTableViewContainer { typealias BottomLoaderTableViewCell = CommonBottomLoader typealias LoadingState = NotificationViewModel.LoadOldestState.Loading - var loadMoreConfigurableTableView: UITableView { return tableView } - var loadMoreConfigurableStateMachine: GKStateMachine { return viewModel.loadoldestStateMachine } + var loadMoreConfigurableTableView: UITableView { tableView } + var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 3e88de9a7..38f24c586 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -5,13 +5,13 @@ // Created by sxiaojian on 2021/4/13. // -import os.log -import func QuartzCore.CACurrentMediaTime -import Foundation import CoreData import CoreDataStack +import Foundation import GameplayKit import MastodonSDK +import os.log +import func QuartzCore.CACurrentMediaTime extension NotificationViewModel { class LoadLatestState: GKState { @@ -22,7 +22,7 @@ extension NotificationViewModel { } override func didEnter(from previousState: GKState?) { - os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription) viewModel?.loadLatestStateMachinePublisher.send(self) } } @@ -31,13 +31,13 @@ extension NotificationViewModel { extension NotificationViewModel.LoadLatestState { class Initial: NotificationViewModel.LoadLatestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self + stateClass == Loading.self } } class Loading: NotificationViewModel.LoadLatestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self + stateClass == Fail.self || stateClass == Idle.self } override func didEnter(from previousState: GKState?) { @@ -63,7 +63,7 @@ extension NotificationViewModel.LoadLatestState { switch completion { case .failure(let error): viewModel.isFetchingLatestNotification.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -76,21 +76,18 @@ extension NotificationViewModel.LoadLatestState { } } .store(in: &viewModel.disposeBag) - - } } class Fail: NotificationViewModel.LoadLatestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self + stateClass == Loading.self || stateClass == Idle.self } } class Idle: NotificationViewModel.LoadLatestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self + stateClass == Loading.self } } - } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift index bf049eace..4885d7025 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -5,11 +5,11 @@ // Created by sxiaojian on 2021/4/14. // -import os.log +import CoreDataStack import Foundation import GameplayKit import MastodonSDK -import CoreDataStack +import os.log extension NotificationViewModel { class LoadOldestState: GKState { @@ -20,7 +20,7 @@ extension NotificationViewModel { } override func didEnter(from previousState: GKState?) { - os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", (#file as NSString).lastPathComponent, #line, #function, debugDescription, previousState.debugDescription) viewModel?.loadOldestStateMachinePublisher.send(self) } } @@ -37,7 +37,7 @@ extension NotificationViewModel.LoadOldestState { class Loading: NotificationViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self + stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self } override func didEnter(from previousState: GKState?) { @@ -80,7 +80,7 @@ extension NotificationViewModel.LoadOldestState { .sink { completion in switch completion { case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break @@ -111,20 +111,20 @@ extension NotificationViewModel.LoadOldestState { class Fail: NotificationViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self || stateClass == Idle.self + stateClass == Loading.self || stateClass == Idle.self } } class Idle: NotificationViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { - return stateClass == Loading.self + stateClass == Loading.self } } class NoMore: NotificationViewModel.LoadOldestState { override func isValidNextState(_ stateClass: AnyClass) -> Bool { // reset state if needs - return stateClass == Idle.self + stateClass == Idle.self } override func didEnter(from previousState: GKState?) { diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index a4c86521f..be516a501 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -5,13 +5,12 @@ // Created by sxiaojian on 2021/4/13. // -import os.log -import UIKit import CoreData import CoreDataStack +import os.log +import UIKit extension NotificationViewModel { - func setupDiffableDataSource( for tableView: UITableView, delegate: NotificationTableViewCellDelegate, @@ -33,19 +32,18 @@ extension NotificationViewModel { requestUserID: userid ) } - } extension NotificationViewModel: NSFetchedResultsControllerDelegate { func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log("%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) guard let tableView = self.tableView else { return } - guard let navigationBar = self.contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } + guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } guard let diffableDataSource = self.diffableDataSource else { return } let oldSnapshot = diffableDataSource.snapshot() @@ -56,7 +54,6 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { managedObjectContext.parent = parentManagedObjectContext managedObjectContext.perform { - let notifications: [MastodonNotification] = { let request = MastodonNotification.sortedFetchRequest request.returnsObjectsAsFaults = false @@ -71,8 +68,8 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) - newSnapshot.appendItems(notifications.map({NotificationItem.notification(objectID: $0.objectID)}), toSection: .main) - if !notifications.isEmpty && self.noMoreNotification.value == false { + newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main) + if !notifications.isEmpty, self.noMoreNotification.value == false { newSnapshot.appendItems([.bottomLoader], toSection: .main) } diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index 85806acf2..f64c07fc9 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -5,16 +5,15 @@ // Created by sxiaojian on 2021/4/12. // -import Foundation import Combine -import UIKit import CoreData import CoreDataStack +import Foundation import GameplayKit import MastodonSDK +import UIKit -final class NotificationViewModel: NSObject { - +final class NotificationViewModel: NSObject { var disposeBag = Set() // input @@ -23,8 +22,8 @@ final class NotificationViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? let viewDidLoad = PassthroughSubject() - let selectedIndex = CurrentValueSubject(0) - let noMoreNotification = CurrentValueSubject(false) + let selectedIndex = CurrentValueSubject(0) + let noMoreNotification = CurrentValueSubject(false) let activeMastodonAuthenticationBox: CurrentValueSubject let fetchedResultsController: NSFetchedResultsController! @@ -33,7 +32,7 @@ final class NotificationViewModel: NSObject { let isFetchingLatestNotification = CurrentValueSubject(false) - //output + // output var diffableDataSource: UITableViewDiffableDataSource! // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { @@ -63,6 +62,7 @@ final class NotificationViewModel: NSObject { stateMachine.enter(LoadOldestState.Initial.self) return stateMachine }() + lazy var loadOldestStateMachinePublisher = CurrentValueSubject(nil) init(context: AppContext) { @@ -71,7 +71,7 @@ final class NotificationViewModel: NSObject { self.fetchedResultsController = { let fetchRequest = MastodonNotification.sortedFetchRequest fetchRequest.returnsObjectsAsFaults = false - fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status),#keyPath(MastodonNotification.account)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(MastodonNotification.status), #keyPath(MastodonNotification.account)] let controller = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context.managedObjectContext, @@ -83,7 +83,7 @@ final class NotificationViewModel: NSObject { }() super.init() - self.fetchedResultsController.delegate = self + fetchedResultsController.delegate = self context.authenticationService.activeMastodonAuthenticationBox .sink(receiveValue: { [weak self] box in guard let self = self else { return } @@ -95,7 +95,7 @@ final class NotificationViewModel: NSObject { .store(in: &disposeBag) notificationPredicate - .compactMap{ $0 } + .compactMap { $0 } .sink { [weak self] predicate in guard let self = self else { return } self.fetchedResultsController.fetchRequest.predicate = predicate @@ -112,12 +112,11 @@ final class NotificationViewModel: NSObject { } .store(in: &disposeBag) - self.viewDidLoad + viewDidLoad .sink { [weak self] in guard let domain = self?.activeMastodonAuthenticationBox.value?.domain else { return } self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain) - } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 6bdf0fd1e..4766aaff8 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -12,7 +12,7 @@ import UIKit final class NotificationStatusTableViewCell: UITableViewCell { static let actionImageBorderWidth: CGFloat = 2 - static let statusPadding: UIEdgeInsets = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) + static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var delegate: NotificationTableViewCellDelegate? diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index a10ff01d6..ce8895acd 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -6,16 +6,16 @@ // import Combine +import CoreDataStack import Foundation import UIKit -import CoreDataStack -protocol NotificationTableViewCellDelegate: class { +protocol NotificationTableViewCellDelegate: AnyObject { var context: AppContext! { get } func parent() -> UIViewController - func userAvatarDidPressed(notification:MastodonNotification) + func userAvatarDidPressed(notification: MastodonNotification) } final class NotificationTableViewCell: UITableViewCell { @@ -113,7 +113,6 @@ extension NotificationTableViewCell { ]) } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index 53595fa92..e2b90ffd7 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -5,10 +5,10 @@ // Created by sxiaojian on 2021/4/13. // -import Foundation import Combine import CoreData import CoreDataStack +import Foundation import MastodonSDK import OSLog @@ -16,8 +16,8 @@ extension APIService { func allNotifications( domain: String, query: Mastodon.API.Notifications.Query, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher, Error> + { let authorization = mastodonAuthenticationBox.userAuthorization return Mastodon.API.Notifications.getNotifications( session: session, @@ -28,10 +28,10 @@ extension APIService { let log = OSLog.api return self.backgroundManagedObjectContext.performChanges { response.value.forEach { notification in - let (mastodonUser,_) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) + let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) var status: Status? if let statusEntity = notification.status { - let (statusInCoreData,_,_) = APIService.CoreData.createOrMergeStatus( + let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus( into: self.backgroundManagedObjectContext, for: nil, domain: domain, @@ -45,7 +45,6 @@ extension APIService { // use constrain to avoid repeated save let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt)) os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.type, notification.account.username) - } } .setFailureType(to: Error.self) From 56d22e9c1c5e6b8df27af2e5300d846a4257cdfe Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 15 Apr 2021 10:37:46 +0800 Subject: [PATCH 249/400] chore: use readableContentGuide --- .../NotificationStatusTableViewCell.swift | 35 +++++++++++-------- .../NotificationTableViewCell.swift | 22 ++++++++---- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 4766aaff8..f05f3d9a1 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -105,24 +105,35 @@ extension NotificationStatusTableViewCell { func configure() { selectionStyle = .none - contentView.addSubview(avatatImageView) + let container = UIView() + container.backgroundColor = .clear + contentView.addSubview(container) + container.constrain([ + container.topAnchor.constraint(equalTo: contentView.topAnchor), + container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + container.addSubview(avatatImageView) avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) - contentView.addSubview(actionImageBackground) - actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationStatusTableViewCell.actionImageBorderWidth, height: 24 + NotificationStatusTableViewCell.actionImageBorderWidth)) + container.addSubview(actionImageBackground) + actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) actionImageBackground.addSubview(actionImageView) actionImageView.constrainToCenter() - - contentView.addSubview(nameLabel) + + container.addSubview(nameLabel) nameLabel.constrain([ - nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), - nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) + nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), + nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) + ]) - contentView.addSubview(actionLabel) + container.addSubview(actionLabel) actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), @@ -130,20 +141,16 @@ extension NotificationStatusTableViewCell { ]) statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - addStatusAndContainer() - } - - func addStatusAndContainer() { statusView.isUserInteractionEnabled = false // remove item don't display statusView.actionToolbarContainer.removeFromSuperview() statusView.avatarView.removeFromSuperview() statusView.usernameLabel.removeFromSuperview() - contentView.addSubview(statusContainer) + container.addSubview(statusContainer) statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) - contentView.addSubview(statusView) + container.addSubview(statusView) statusView.pin(top: NotificationStatusTableViewCell.statusPadding.top, left: NotificationStatusTableViewCell.statusPadding.left, bottom: NotificationStatusTableViewCell.statusPadding.bottom, right: NotificationStatusTableViewCell.statusPadding.right) } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index ce8895acd..e42b08114 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -87,25 +87,35 @@ extension NotificationTableViewCell { func configure() { selectionStyle = .none - contentView.addSubview(avatatImageView) + let container = UIView() + container.backgroundColor = .clear + contentView.addSubview(container) + container.constrain([ + container.topAnchor.constraint(equalTo: contentView.topAnchor), + container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), + container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + container.addSubview(avatatImageView) avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) - contentView.addSubview(actionImageBackground) + container.addSubview(actionImageBackground) actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) actionImageBackground.addSubview(actionImageView) actionImageView.constrainToCenter() - contentView.addSubview(nameLabel) + container.addSubview(nameLabel) nameLabel.constrain([ - nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 24), contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24), - nameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 61) + nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) ]) - contentView.addSubview(actionLabel) + container.addSubview(actionLabel) actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), From 845080a69dfda8ea6b9e423a8e5c4e5b8d9e09fb Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 15 Apr 2021 10:53:03 +0800 Subject: [PATCH 250/400] fix: NotificationCell nameLabel and actionLabel layout issue --- .../TableViewCell/NotificationStatusTableViewCell.swift | 2 +- .../TableViewCell/NotificationTableViewCell.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index f05f3d9a1..0f7a8fbdc 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -137,7 +137,7 @@ extension NotificationStatusTableViewCell { actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) + container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) ]) statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index e42b08114..238d9c67f 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -111,7 +111,7 @@ extension NotificationTableViewCell { container.addSubview(nameLabel) nameLabel.constrain([ nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 24), - contentView.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24), + container.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24), nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) ]) @@ -119,7 +119,7 @@ extension NotificationTableViewCell { actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - contentView.trailingAnchor.constraint(equalTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) + container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) ]) } From 2507a7324e0d5ccbe5a906a4c1209eae6846b7c9 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Apr 2021 11:05:19 +0800 Subject: [PATCH 251/400] fix: set missing font for relationship action button --- .../Profile/Header/View/ProfileRelationshipActionButton.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index d4b57ffe4..c74560386 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -29,6 +29,8 @@ final class ProfileRelationshipActionButton: RoundedEdgesButton { extension ProfileRelationshipActionButton { private func _init() { + titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false addSubview(actvityIndicatorView) NSLayoutConstraint.activate([ From 39c635705e05e9dd8356f1fef8181566d3bff6b5 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Apr 2021 11:21:07 +0800 Subject: [PATCH 252/400] chore: update background color --- Mastodon/Scene/Share/View/Decoration/SawToothView.swift | 4 ++-- .../Scene/Share/View/TableviewCell/StatusTableViewCell.swift | 2 +- .../View/TableviewCell/TimelineLoaderTableViewCell.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift index ef1c89cc0..8f41abbb3 100644 --- a/Mastodon/Scene/Share/View/Decoration/SawToothView.swift +++ b/Mastodon/Scene/Share/View/Decoration/SawToothView.swift @@ -22,7 +22,7 @@ final class SawToothView: UIView { } func _init() { - backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + backgroundColor = Asset.Colors.Background.secondarySystemBackground.color } override func draw(_ rect: CGRect) { @@ -37,7 +37,7 @@ final class SawToothView: UIView { } bezierPath.addLine(to: CGPoint(x: 0, y: bottomY)) bezierPath.close() - Asset.Colors.Background.secondaryGroupedSystemBackground.color.setFill() + Asset.Colors.Background.systemBackground.color.setFill() bezierPath.fill() bezierPath.lineWidth = 0 bezierPath.stroke() diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 9887eaa9f..afa044b67 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -103,7 +103,7 @@ extension StatusTableViewCell { private func _init() { backgroundColor = Asset.Colors.Background.systemBackground.color - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index 749f2eb51..da7420e43 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -22,7 +22,7 @@ class TimelineLoaderTableViewCell: UITableViewCell { let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont - button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.backgroundColor = Asset.Colors.Background.systemBackground.color button.setTitleColor(Asset.Colors.Button.normal.color, for: .normal) button.setTitle(L10n.Common.Controls.Timeline.Loader.loadMissingPosts, for: .normal) button.setTitle("", for: .disabled) From 12a3164a217f223aea593ab864776c1edb952c05 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Apr 2021 11:21:33 +0800 Subject: [PATCH 253/400] fix: segmented control selection missing update issue --- Mastodon/Scene/Profile/ProfileViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 671f7c155..8fc915a0a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -625,6 +625,11 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) + // update segemented control + if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { + profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index + } + // save content offset overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y From 83904126ddd404ca9808ba514bb71675fb8802a7 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Apr 2021 11:38:47 +0800 Subject: [PATCH 254/400] chore: update face smiling icon --- Mastodon/Generated/Assets.swift | 3 + .../Assets.xcassets/Human/Contents.json | 9 ++ .../Contents.json | 25 +++++ .../emojiIconDark.pdf | 97 +++++++++++++++++ .../emojiIconLight.pdf | 103 ++++++++++++++++++ .../Compose/View/ComposeToolbarView.swift | 7 +- 6 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Human/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf create mode 100644 Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 0f20937fd..cd655e077 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -90,6 +90,9 @@ internal enum Asset { internal enum Connectivity { internal static let photoFillSplit = ImageAsset(name: "Connectivity/photo.fill.split") } + internal enum Human { + internal static let faceSmilingAdaptive = ImageAsset(name: "Human/face.smiling.adaptive") + } internal enum Scene { internal enum Compose { internal static let background = ColorAsset(name: "Scene/Compose/background") diff --git a/Mastodon/Resources/Assets.xcassets/Human/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/Contents.json new file mode 100644 index 000000000..6e965652d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/Contents.json @@ -0,0 +1,9 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "provides-namespace" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json new file mode 100644 index 000000000..df869a35c --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "emojiIconLight.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "emojiIconDark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf new file mode 100644 index 000000000..77c6c2d32 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconDark.pdf @@ -0,0 +1,97 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.225600 0.613812 0.894400 scn +48.000000 0.000000 m +74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c +96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c +21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c +0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c +h +48.000023 39.999962 m +38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c +22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c +18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c +65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c +77.333359 42.666630 73.810692 43.018627 72.000023 42.666630 c +64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c +h +38.666645 59.999981 m +38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c +28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c +25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c +35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c +h +63.999977 50.666649 m +67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c +70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c +60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c +57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c +h +48.000000 34.666645 m +32.000000 34.666645 24.000000 37.333313 24.000000 37.333313 c +24.000000 37.333313 29.333334 26.666649 48.000000 26.666649 c +66.666672 26.666649 72.000000 37.333313 72.000000 37.333313 c +72.000000 37.333313 64.000000 34.666645 48.000000 34.666645 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1603 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001693 00000 n +0000001716 00000 n +0000001889 00000 n +0000001963 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2022 +%%EOF \ No newline at end of file diff --git a/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf new file mode 100644 index 000000000..61f471d6d --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Human/face.smiling.adaptive.imageset/emojiIconLight.pdf @@ -0,0 +1,103 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm +0.168627 0.564706 0.850980 scn +90.000000 48.000000 m +90.000000 24.804031 71.195969 6.000000 48.000000 6.000000 c +24.804039 6.000000 6.000000 24.804031 6.000000 48.000000 c +6.000000 71.195961 24.804041 90.000000 48.000000 90.000000 c +71.195969 90.000000 90.000000 71.195961 90.000000 48.000000 c +h +48.000000 0.000000 m +74.509674 0.000000 96.000000 21.490326 96.000000 48.000000 c +96.000000 74.509666 74.509674 96.000000 48.000000 96.000000 c +21.490332 96.000000 0.000000 74.509666 0.000000 48.000000 c +0.000000 21.490326 21.490332 0.000000 48.000000 0.000000 c +h +38.666645 59.999981 m +38.666645 54.845322 35.681877 50.666649 31.999979 50.666649 c +28.318081 50.666649 25.333313 54.845322 25.333313 59.999981 c +25.333313 65.154640 28.318081 69.333313 31.999979 69.333313 c +35.681877 69.333313 38.666645 65.154640 38.666645 59.999981 c +h +63.999977 50.666649 m +67.681877 50.666649 70.666641 54.845322 70.666641 59.999981 c +70.666641 65.154640 67.681877 69.333313 63.999977 69.333313 c +60.318081 69.333313 57.333313 65.154640 57.333313 59.999981 c +57.333313 54.845322 60.318081 50.666649 63.999977 50.666649 c +h +48.000023 39.999962 m +38.338688 39.999962 31.928020 41.125294 24.000021 42.666630 c +22.189354 43.015961 18.666687 42.666630 18.666687 37.333294 c +18.666687 26.666626 30.920021 13.333298 48.000023 13.333298 c +65.077354 13.333298 77.333359 26.666626 77.333359 37.333294 c +77.333359 42.666630 73.810684 43.018627 72.000023 42.666630 c +64.072021 41.125294 57.661354 39.999962 48.000023 39.999962 c +h +24.000000 37.333313 m +24.000000 37.333313 32.000000 34.666645 48.000000 34.666645 c +64.000000 34.666645 72.000000 37.333313 72.000000 37.333313 c +72.000000 37.333313 66.666672 26.666649 48.000000 26.666649 c +29.333334 26.666649 24.000000 37.333313 24.000000 37.333313 c +h +f* +n +Q + +endstream +endobj + +3 0 obj + 1869 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 96.000000 96.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001959 00000 n +0000001982 00000 n +0000002155 00000 n +0000002229 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +2288 +%%EOF \ No newline at end of file diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 9745ad954..99288a5e0 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -41,7 +41,10 @@ final class ComposeToolbarView: UIView { let emojiButton: UIButton = { let button = HighlightDimmableButton() ComposeToolbarView.configureToolbarButtonAppearance(button: button) - button.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + let image = Asset.Human.faceSmilingAdaptive.image + .af.imageScaled(to: CGSize(width: 20, height: 20)) + .withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) return button }() @@ -203,12 +206,10 @@ extension ComposeToolbarView { switch traitCollection.userInterfaceStyle { case .light: mediaButton.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) - emojiButton.setImage(UIImage(systemName: "face.smiling", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) case .dark: mediaButton.setImage(UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) - emojiButton.setImage(UIImage(systemName: "face.smiling.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal) default: From ccb74b08f7bb341d73285f6d72093ad1756e947b Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Apr 2021 12:10:43 +0800 Subject: [PATCH 255/400] feat: make reply to foldable in compose scene --- .../Section/ComposeStatusSection.swift | 3 ++ ...iedToStatusContentCollectionViewCell.swift | 7 +++ .../Scene/Compose/ComposeViewController.swift | 51 ++++++++++++++++++- .../Compose/ComposeViewModel+Diffable.swift | 1 + Mastodon/Scene/Compose/ComposeViewModel.swift | 9 ++++ 5 files changed, 70 insertions(+), 1 deletion(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 49f4fb5b7..0e7c574b4 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -34,6 +34,7 @@ extension ComposeStatusSection { dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, composeKind: ComposeKind, + repliedToCellFrameSubscriber: CurrentValueSubject, customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: TextEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, @@ -70,6 +71,8 @@ extension ComposeStatusSection { cell.statusView.activeTextLabel.configure(content: status.content) // set date cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow + + cell.framePublisher.assign(to: \.value, on: repliedToCellFrameSubscriber).store(in: &cell.disposeBag) } return cell case .input(let replyToStatusObjectID, let attribute): diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 35af3c0ac..95e9b4f1a 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -14,6 +14,8 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel let statusView = StatusView() + let framePublisher = PassthroughSubject() + override func prepareForReuse() { super.prepareForReuse() @@ -32,6 +34,11 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel _init() } + override func layoutSubviews() { + super.layoutSubviews() + framePublisher.send(bounds) + } + } extension ComposeRepliedToStatusContentCollectionViewCell { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 636d2de63..b463f13ac 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -31,7 +31,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) button.setTitleColor(.white, for: .normal) - button.contentEdgeInsets = UIEdgeInsets(top: 5.5, left: 16, bottom: 5.5, right: 16) // set 28pt height + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.adjustsImageWhenHighlighted = false return button }() @@ -50,6 +50,7 @@ final class ComposeViewController: UIViewController, NeedsDependency { collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self)) collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self)) collectionView.backgroundColor = Asset.Scene.Compose.background.color + collectionView.alwaysBounceVertical = true return collectionView }() @@ -331,6 +332,24 @@ extension ComposeViewController { } }) .store(in: &disposeBag) + + // setup snap behavior + Publishers.CombineLatest( + viewModel.repliedToCellFrame.removeDuplicates().eraseToAnyPublisher(), + viewModel.collectionViewState.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] repliedToCellFrame, collectionViewState in + guard let self = self else { return } + guard repliedToCellFrame != .zero else { return } + switch collectionViewState { + case .fold: + self.collectionView.contentInset.top = -repliedToCellFrame.height + case .expand: + self.collectionView.contentInset.top = 0 + } + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { @@ -754,6 +773,32 @@ extension ComposeViewController: ComposeToolbarViewDelegate { } +// MARK: - UIScrollViewDelegate +extension ComposeViewController { + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + guard scrollView === collectionView else { return } + + let repliedToCellFrame = viewModel.repliedToCellFrame.value + guard repliedToCellFrame != .zero else { return } + let throttle = viewModel.repliedToCellFrame.value.height - scrollView.adjustedContentInset.top + // print("\(throttle) - \(scrollView.contentOffset.y)") + + switch viewModel.collectionViewState.value { + case .fold: + if scrollView.contentOffset.y < throttle { + viewModel.collectionViewState.value = .expand + } + os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function) + + case .expand: + if scrollView.contentOffset.y > -44 { + viewModel.collectionViewState.value = .fold + os_log("%{public}s[%{public}ld], %{public}s: expand", ((#file as NSString).lastPathComponent), #line, #function) + } + } + } +} + // MARK: - UITableViewDelegate extension ComposeViewController: UICollectionViewDelegate { @@ -790,6 +835,10 @@ extension ComposeViewController: UICollectionViewDelegate { // MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .fullScreen + } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss.value diff --git a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift index 4d5a39be1..6581e1fb1 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+Diffable.swift @@ -27,6 +27,7 @@ extension ComposeViewModel { dependency: dependency, managedObjectContext: context.managedObjectContext, composeKind: composeKind, + repliedToCellFrameSubscriber: repliedToCellFrame, customEmojiPickerInputViewModel: customEmojiPickerInputViewModel, textEditorViewTextAttributesDelegate: textEditorViewTextAttributesDelegate, composeStatusAttachmentTableViewCellDelegate: composeStatusAttachmentTableViewCellDelegate, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 777059adb..ef744d0b3 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -30,6 +30,7 @@ final class ComposeViewModel { let activeAuthentication: CurrentValueSubject let activeAuthenticationBox: CurrentValueSubject let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make intial event emit + let repliedToCellFrame = CurrentValueSubject(.zero) // output var diffableDataSource: UICollectionViewDiffableDataSource! @@ -56,6 +57,7 @@ final class ComposeViewModel { let isMediaToolbarButtonEnabled = CurrentValueSubject(true) let isPollToolbarButtonEnabled = CurrentValueSubject(true) let characterCount = CurrentValueSubject(0) + let collectionViewState = CurrentValueSubject(.fold) // for hashtag: #' ' // for mention: @' ' @@ -377,6 +379,13 @@ final class ComposeViewModel { } +extension ComposeViewModel { + enum CollectionViewState { + case fold // snap to input + case expand // snap to reply + } +} + extension ComposeViewModel { func createNewPollOptionIfPossible() { guard pollOptionAttributes.value.count < 4 else { return } From ecf622b86672167c84507d8c8861399a66e798bd Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 15 Apr 2021 12:35:40 +0800 Subject: [PATCH 256/400] fix: statusView layout issue --- ...tagTimelineViewModel+LoadOldestState.swift | 8 +++++--- ...omeTimelineViewModel+LoadOldestState.swift | 8 +++++--- ...otificationViewModel+LoadOldestState.swift | 8 +++++--- .../NotificationViewModel+diffable.swift | 19 ++++++++++--------- .../NotificationStatusTableViewCell.swift | 8 ++++---- .../SearchViewModel+LoadOldestState.swift | 8 +++++--- .../TableViewCell/CommonBottomLoader.swift | 3 --- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift index e5c78f3d5..137373647 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+LoadOldestState.swift @@ -123,9 +123,11 @@ extension HashtagTimelineViewModel.LoadOldestState { assertionFailure() return } - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index aaabd7a8b..a74d03a52 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -103,9 +103,11 @@ extension HomeTimelineViewModel.LoadOldestState { assertionFailure() return } - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } } } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift index 4885d7025..a26dedeeb 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -133,9 +133,11 @@ extension NotificationViewModel.LoadOldestState { assertionFailure() return } - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } } } } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index be516a501..774620f8a 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -46,7 +46,6 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { guard let navigationBar = contentOffsetAdjustableTimelineViewControllerDelegate?.navigationBar() else { return } guard let diffableDataSource = self.diffableDataSource else { return } - let oldSnapshot = diffableDataSource.snapshot() let predicate = fetchedResultsController.fetchRequest.predicate let parentManagedObjectContext = fetchedResultsController.managedObjectContext @@ -66,16 +65,18 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { } }() - var newSnapshot = NSDiffableDataSourceSnapshot() - newSnapshot.appendSections([.main]) - newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main) - if !notifications.isEmpty, self.noMoreNotification.value == false { - newSnapshot.appendItems([.bottomLoader], toSection: .main) - } - DispatchQueue.main.async { + let oldSnapshot = diffableDataSource.snapshot() + var newSnapshot = NSDiffableDataSourceSnapshot() + newSnapshot.appendSections([.main]) + newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main) + if !notifications.isEmpty, self.noMoreNotification.value == false { + newSnapshot.appendItems([.bottomLoader], toSection: .main) + } guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot) + diffableDataSource.apply(newSnapshot, animatingDifferences: false) { + tableView.reloadData() + } self.isFetchingLatestNotification.value = false return } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 0f7a8fbdc..6bea35ead 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -58,7 +58,7 @@ final class NotificationStatusTableViewCell: UITableViewCell { return label }() - let statusContainer: UIView = { + let statusBorder: UIView = { let view = UIView() view.backgroundColor = .clear view.layer.cornerRadius = 6 @@ -147,8 +147,8 @@ extension NotificationStatusTableViewCell { statusView.avatarView.removeFromSuperview() statusView.usernameLabel.removeFromSuperview() - container.addSubview(statusContainer) - statusContainer.pin(top: 40, left: 63, bottom: 14, right: 14) + container.addSubview(statusBorder) + statusBorder.pin(top: 40, left: 63, bottom: 14, right: 14) container.addSubview(statusView) statusView.pin(top: NotificationStatusTableViewCell.statusPadding.top, left: NotificationStatusTableViewCell.statusPadding.left, bottom: NotificationStatusTableViewCell.statusPadding.bottom, right: NotificationStatusTableViewCell.statusPadding.right) @@ -156,7 +156,7 @@ extension NotificationStatusTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - statusContainer.layer.borderColor = Asset.Colors.Border.notification.color.cgColor + statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor } } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index c76ab202c..b486df774 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -136,9 +136,11 @@ extension SearchViewModel.LoadOldestState { assertionFailure() return } - var snapshot = diffableDataSource.snapshot() - snapshot.deleteItems([.bottomLoader]) - diffableDataSource.apply(snapshot) + DispatchQueue.main.async { + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) + } } } } diff --git a/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift index bb2ae9ce0..2d529972e 100644 --- a/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift +++ b/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift @@ -43,8 +43,5 @@ final class CommonBottomLoader: UITableViewCell { backgroundColor = Asset.Colors.Background.systemGroupedBackground.color contentView.addSubview(activityIndicatorView) activityIndicatorView.constrainToCenter() - NSLayoutConstraint.activate([ - contentView.heightAnchor.constraint(equalToConstant: 44) - ]) } } From 0ebf86bd43ce71b7a13fd6bcb5b6902892d81e13 Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 15 Apr 2021 17:17:19 +0800 Subject: [PATCH 257/400] docs: add comments for hard code --- Mastodon/Scene/Settings/SettingsViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 59219ab44..9ffec7f8d 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -105,6 +105,7 @@ class SettingsViewModel: NSObject, NeedsDependency { typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery let domain = activeMastodonAuthenticationBox.domain let query = Query( + // FIXME: to replace the correct endpoint, p256dh, auth endpoint: "http://www.google.com", p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", auth: "4vQK-SvRAN5eo-8ASlrwA==", From ca7eb7bb1203ef0f912b3ac59b1453c76106186b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 16 Apr 2021 13:45:54 +0800 Subject: [PATCH 258/400] chore: code format --- .../CoreData.xcdatamodel/contents | 5 +- CoreDataStack/Entity/Notification.swift | 55 +++++++++------ Mastodon.xcodeproj/project.pbxproj | 4 -- .../Diffiable/Item/NotificationItem.swift | 4 +- .../Section/NotificationSection.swift | 4 +- .../Section/SearchResultSection.swift | 2 +- Mastodon/Extension/UIView+Constraint.swift | 5 +- .../NotificationViewController.swift | 12 ++-- ...otificationViewModel+LoadLatestState.swift | 8 ++- ...otificationViewModel+LoadOldestState.swift | 20 +++--- .../Notification/NotificationViewModel.swift | 17 +++-- .../NotificationStatusTableViewCell.swift | 14 ++-- .../NotificationTableViewCell.swift | 5 +- .../SearchViewController+Searching.swift | 2 +- .../Scene/Search/SearchViewController.swift | 2 +- .../TableViewCell/CommonBottomLoader.swift | 47 ------------- .../APIService/APIService+Notification.swift | 70 ++++++++++--------- .../API/Mastodon+API+Notifications.swift | 18 ++--- 18 files changed, 130 insertions(+), 164 deletions(-) delete mode 100644 Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 2569da5e6..bc7a20d5d 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -70,8 +70,9 @@ - + + @@ -223,7 +224,7 @@ - + diff --git a/CoreDataStack/Entity/Notification.swift b/CoreDataStack/Entity/Notification.swift index 8a0595f6c..31c361aa4 100644 --- a/CoreDataStack/Entity/Notification.swift +++ b/CoreDataStack/Entity/Notification.swift @@ -12,13 +12,14 @@ public final class MastodonNotification: NSManagedObject { public typealias ID = UUID @NSManaged public private(set) var identifier: ID @NSManaged public private(set) var id: String - @NSManaged public private(set) var domain: String @NSManaged public private(set) var createAt: Date @NSManaged public private(set) var updatedAt: Date - @NSManaged public private(set) var type: String + @NSManaged public private(set) var typeRaw: String @NSManaged public private(set) var account: MastodonUser @NSManaged public private(set) var status: Status? + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var userID: String } extension MastodonNotification { @@ -26,12 +27,6 @@ extension MastodonNotification { super.awakeFromInsert() setPrimitiveValue(UUID(), forKey: #keyPath(MastodonNotification.identifier)) } - - public override func willSave() { - super.willSave() - setPrimitiveValue(Date(), forKey: #keyPath(MastodonNotification.updatedAt)) - } - } public extension MastodonNotification { @@ -39,16 +34,19 @@ public extension MastodonNotification { static func insert( into context: NSManagedObjectContext, domain: String, + userID: String, + networkDate: Date, property: Property ) -> MastodonNotification { let notification: MastodonNotification = context.insertObject() notification.id = property.id notification.createAt = property.createdAt - notification.updatedAt = property.createdAt - notification.type = property.type + notification.updatedAt = networkDate + notification.typeRaw = property.typeRaw notification.account = property.account notification.status = property.status notification.domain = domain + notification.userID = userID return notification } } @@ -56,19 +54,20 @@ public extension MastodonNotification { public extension MastodonNotification { struct Property { public init(id: String, - type: String, + typeRaw: String, account: MastodonUser, status: Status?, - createdAt: Date) { + createdAt: Date + ) { self.id = id - self.type = type + self.typeRaw = typeRaw self.account = account self.status = status self.createdAt = createdAt } public let id: String - public let type: String + public let typeRaw: String public let account: MastodonUser public let status: Status? public let createdAt: Date @@ -76,19 +75,31 @@ public extension MastodonNotification { } extension MastodonNotification { - public static func predicate(domain: String) -> NSPredicate { + static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.domain), domain) } - static func predicate(type: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.type), type) + static func predicate(userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.userID), userID) } - public static func predicate(domain: String, type: String) -> NSPredicate { - return NSCompoundPredicate(andPredicateWithSubpredicates: [ - MastodonNotification.predicate(domain: domain), - MastodonNotification.predicate(type: type) - ]) + static func predicate(typeRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(MastodonNotification.typeRaw), typeRaw) + } + + public static func predicate(domain: String, userID: String, typeRaw: String? = nil) -> NSPredicate { + if let typeRaw = typeRaw { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonNotification.predicate(domain: domain), + MastodonNotification.predicate(typeRaw: typeRaw), + MastodonNotification.predicate(userID: userID), + ]) + } else { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + MastodonNotification.predicate(domain: domain), + MastodonNotification.predicate(userID: userID) + ]) + } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d088d3176..3244dcd33 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -37,7 +37,6 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; - 2D19864F261C372A00F0B013 /* CommonBottomLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */; }; 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */; }; 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */; }; 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B7F25F5F45E00143C56 /* UIImage.swift */; }; @@ -427,7 +426,6 @@ 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; - 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonBottomLoader.swift; sourceTree = ""; }; 2D198654261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewModel+LoadOldestState.swift"; sourceTree = ""; }; 2D206B7125F5D27F00143C56 /* AudioContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerView.swift; sourceTree = ""; }; 2D206B7F25F5F45E00143C56 /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; @@ -1154,7 +1152,6 @@ isa = PBXGroup; children = ( 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */, - 2D19864E261C372A00F0B013 /* CommonBottomLoader.swift */, ); path = TableViewCell; sourceTree = ""; @@ -2272,7 +2269,6 @@ DBB5256E2612D5A1002F1F29 /* ProfileStatusDashboardView.swift in Sources */, 2D24E1232626ED9D00A59D4F /* UIView+Gesture.swift in Sources */, DB45FAE325CA7181005A8AC7 /* MastodonUser.swift in Sources */, - 2D19864F261C372A00F0B013 /* CommonBottomLoader.swift in Sources */, DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */, 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */, DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index c160eac5e..ba0d0c140 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -17,10 +17,10 @@ enum NotificationItem { extension NotificationItem: Equatable { static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { switch (lhs, rhs) { - case (.bottomLoader, .bottomLoader): - return true case (.notification(let idLeft), .notification(let idRight)): return idLeft == idRight + case (.bottomLoader, .bottomLoader): + return true default: return false } diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 5e3cd2d9e..0b63bb241 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -33,7 +33,7 @@ extension NotificationSection { case .notification(let objectID): let notification = managedObjectContext.object(with: objectID) as! MastodonNotification - let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.type) + let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) let timeText = notification.createAt.shortTimeAgoSinceNow @@ -128,7 +128,7 @@ extension NotificationSection { return cell } case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: CommonBottomLoader.self)) as! CommonBottomLoader + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell } diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index e01063c86..1b9230ee0 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -44,7 +44,7 @@ extension SearchResultSection { cell.config(with: user) return cell case .bottomLoader: - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: CommonBottomLoader.self)) as! CommonBottomLoader + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell } diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift index baa923ada..ded8846d4 100644 --- a/Mastodon/Extension/UIView+Constraint.swift +++ b/Mastodon/Extension/UIView+Constraint.swift @@ -174,8 +174,9 @@ extension UIView { guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } translatesAutoresizingMaskIntoConstraints = false constrain([ - widthAnchor.constraint(equalToConstant: toSize.width), - heightAnchor.constraint(equalToConstant: toSize.height)]) + widthAnchor.constraint(equalToConstant: toSize.width).priority(.required - 1), + heightAnchor.constraint(equalToConstant: toSize.height).priority(.required - 1) + ]) } func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index becf86771..ddc997a5d 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -33,7 +33,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) - tableView.register(CommonBottomLoader.self, forCellReuseIdentifier: String(describing: CommonBottomLoader.self)) + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.tableFooterView = UIView() tableView.rowHeight = UITableView.automaticDimension return tableView @@ -111,15 +111,15 @@ extension NotificationViewController { extension NotificationViewController { @objc private func segmentedControlValueChanged(_ sender: UISegmentedControl) { os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", (#file as NSString).lastPathComponent, #line, #function, sender.selectedSegmentIndex) - guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain else { + guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = viewModel.activeMastodonAuthenticationBox.value?.userID else { return } if sender.selectedSegmentIndex == 0 { - viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) } else { - viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, type: Mastodon.Entity.Notification.NotificationType.mention.rawValue) + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain,userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) } - viewModel.selectedIndex.value = sender.selectedSegmentIndex + viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment.init(rawValue: sender.selectedSegmentIndex)! } @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { @@ -196,7 +196,7 @@ extension NotificationViewController { } extension NotificationViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = CommonBottomLoader + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias LoadingState = NotificationViewModel.LoadOldestState.Loading var loadMoreConfigurableTableView: UITableView { tableView } var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 38f24c586..0e6b0d622 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -53,12 +53,14 @@ extension NotificationViewModel.LoadLatestState { sinceID: nil, minID: nil, limit: nil, - excludeTypes: Mastodon.API.Notifications.allExcludeTypes(), - accountID: nil) + excludeTypes: [.followRequest], + accountID: nil + ) viewModel.context.apiService.allNotifications( domain: activeMastodonAuthenticationBox.domain, query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox) + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) .sink { completion in switch completion { case .failure(let error): diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift index a26dedeeb..8075ce375 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadOldestState.swift @@ -50,7 +50,7 @@ extension NotificationViewModel.LoadOldestState { } let notifications: [MastodonNotification]? = { let request = MastodonNotification.sortedFetchRequest - request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain) + request.predicate = MastodonNotification.predicate(domain: activeMastodonAuthenticationBox.domain, userID: activeMastodonAuthenticationBox.userID) request.returnsObjectsAsFaults = false do { return try self.viewModel?.context.managedObjectContext.fetch(request) @@ -71,12 +71,13 @@ extension NotificationViewModel.LoadOldestState { sinceID: nil, minID: nil, limit: nil, - excludeTypes: Mastodon.API.Notifications.allExcludeTypes(), + excludeTypes: [.followRequest], accountID: nil) viewModel.context.apiService.allNotifications( domain: activeMastodonAuthenticationBox.domain, query: query, - mastodonAuthenticationBox: activeMastodonAuthenticationBox) + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) .sink { completion in switch completion { case .failure(let error): @@ -89,16 +90,17 @@ extension NotificationViewModel.LoadOldestState { stateMachine.enter(Idle.self) } receiveValue: { [weak viewModel] response in guard let viewModel = viewModel else { return } - if viewModel.selectedIndex.value == 1 { - viewModel.noMoreNotification.value = response.value.isEmpty - let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } - if list.isEmpty { + switch viewModel.selectedIndex.value { + case .EveryThing: + if response.value.isEmpty { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) } - } else { - if response.value.isEmpty { + case .Mentions: + viewModel.noMoreNotification.value = response.value.isEmpty + let list = response.value.filter { $0.type == Mastodon.Entity.Notification.NotificationType.mention } + if list.isEmpty { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index f64c07fc9..e026af732 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -22,7 +22,7 @@ final class NotificationViewModel: NSObject { weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate? let viewDidLoad = PassthroughSubject() - let selectedIndex = CurrentValueSubject(0) + let selectedIndex = CurrentValueSubject(.EveryThing) let noMoreNotification = CurrentValueSubject(false) let activeMastodonAuthenticationBox: CurrentValueSubject @@ -88,8 +88,8 @@ final class NotificationViewModel: NSObject { .sink(receiveValue: { [weak self] box in guard let self = self else { return } self.activeMastodonAuthenticationBox.value = box - if let domain = box?.domain { - self.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + if let domain = box?.domain, let userID = box?.userID { + self.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) } }) .store(in: &disposeBag) @@ -115,9 +115,16 @@ final class NotificationViewModel: NSObject { viewDidLoad .sink { [weak self] in - guard let domain = self?.activeMastodonAuthenticationBox.value?.domain else { return } - self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain) + guard let domain = self?.activeMastodonAuthenticationBox.value?.domain, let userID = self?.activeMastodonAuthenticationBox.value?.userID else { return } + self?.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) } .store(in: &disposeBag) } } + +extension NotificationViewModel { + enum NotificationSegment: Int { + case EveryThing + case Mentions + } +} diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 6bea35ead..dc3f49bb0 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -103,7 +103,6 @@ final class NotificationStatusTableViewCell: UITableViewCell { extension NotificationStatusTableViewCell { func configure() { - selectionStyle = .none let container = UIView() container.backgroundColor = .clear @@ -117,11 +116,11 @@ extension NotificationStatusTableViewCell { container.addSubview(avatatImageView) avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) - avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) + avatatImageView.pin(top: 12, left: 0, bottom: nil, right: nil) container.addSubview(actionImageBackground) actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) - actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) + actionImageBackground.pin(top: 33, left: 21, bottom: nil, right: nil) actionImageBackground.addSubview(actionImageView) actionImageView.constrainToCenter() @@ -130,22 +129,21 @@ extension NotificationStatusTableViewCell { nameLabel.constrain([ nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) - ]) container.addSubview(actionLabel) actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) + container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4) ]) statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color statusView.isUserInteractionEnabled = false // remove item don't display - statusView.actionToolbarContainer.removeFromSuperview() - statusView.avatarView.removeFromSuperview() - statusView.usernameLabel.removeFromSuperview() + statusView.actionToolbarContainer.isHidden = true + statusView.avatarView.isHidden = true + statusView.usernameLabel.isHidden = true container.addSubview(statusBorder) statusBorder.pin(top: 40, left: 63, bottom: 14, right: 14) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 238d9c67f..cda4d75d7 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -85,7 +85,6 @@ final class NotificationTableViewCell: UITableViewCell { extension NotificationTableViewCell { func configure() { - selectionStyle = .none let container = UIView() container.backgroundColor = .clear @@ -99,7 +98,7 @@ extension NotificationTableViewCell { container.addSubview(avatatImageView) avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) - avatatImageView.pin(top: 12, left: 12, bottom: nil, right: nil) + avatatImageView.pin(top: 12, left: 0, bottom: nil, right: nil) container.addSubview(actionImageBackground) actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) @@ -119,7 +118,7 @@ extension NotificationTableViewCell { actionLabel.constrain([ actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4).priority(.defaultLow) + container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4) ]) } diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 43e5d397c..86a27e03d 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -17,7 +17,7 @@ extension SearchViewController { func setupSearchingTableView() { searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) - searchingTableView.register(CommonBottomLoader.self, forCellReuseIdentifier: String(describing: CommonBottomLoader.self)) + searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) view.addSubview(searchingTableView) searchingTableView.constrain([ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index dc9414585..4fee226bf 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -227,7 +227,7 @@ extension SearchViewController: UISearchBarDelegate { } extension SearchViewController: LoadMoreConfigurableTableViewContainer { - typealias BottomLoaderTableViewCell = CommonBottomLoader + typealias BottomLoaderTableViewCell = TimelineBottomLoaderTableViewCell typealias LoadingState = SearchViewModel.LoadOldestState.Loading var loadMoreConfigurableTableView: UITableView { searchingTableView } var loadMoreConfigurableStateMachine: GKStateMachine { viewModel.loadoldestStateMachine } diff --git a/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift b/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift deleted file mode 100644 index 2d529972e..000000000 --- a/Mastodon/Scene/Search/TableViewCell/CommonBottomLoader.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CommonBottomLoader.swift -// Mastodon -// -// Created by sxiaojian on 2021/4/6. -// - -import Foundation -import UIKit - -final class CommonBottomLoader: UITableViewCell { - let activityIndicatorView: UIActivityIndicatorView = { - let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.tintColor = Asset.Colors.Label.primary.color - activityIndicatorView.hidesWhenStopped = true - return activityIndicatorView - }() - - override func prepareForReuse() { - super.prepareForReuse() - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - func startAnimating() { - activityIndicatorView.startAnimating() - } - - func stopAnimating() { - activityIndicatorView.stopAnimating() - } - - func _init() { - selectionStyle = .none - backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - contentView.addSubview(activityIndicatorView) - activityIndicatorView.constrainToCenter() - } -} diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index e2b90ffd7..ee8f5186c 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -16,48 +16,52 @@ extension APIService { func allNotifications( domain: String, query: Mastodon.API.Notifications.Query, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher, Error> - { + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let userID = mastodonAuthenticationBox.userID return Mastodon.API.Notifications.getNotifications( session: session, domain: domain, query: query, - authorization: authorization) - .flatMap { response -> AnyPublisher, Error> in - let log = OSLog.api - return self.backgroundManagedObjectContext.performChanges { - response.value.forEach { notification in - let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) - var status: Status? - if let statusEntity = notification.status { - let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus( - into: self.backgroundManagedObjectContext, - for: nil, - domain: domain, - entity: statusEntity, - statusCache: nil, - userCache: nil, - networkDate: Date(), - log: log) - status = statusInCoreData - } - // use constrain to avoid repeated save - let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, property: MastodonNotification.Property(id: notification.id, type: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt)) - os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.type, notification.account.username) + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { notification in + let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) + var status: Status? + if let statusEntity = notification.status { + let (statusInCoreData, _, _) = APIService.CoreData.createOrMergeStatus( + into: self.backgroundManagedObjectContext, + for: nil, + domain: domain, + entity: statusEntity, + statusCache: nil, + userCache: nil, + networkDate: Date(), + log: log + ) + status = statusInCoreData } + // use constrain to avoid repeated save + let property = MastodonNotification.Property(id: notification.id, typeRaw: notification.type.rawValue, account: mastodonUser, status: status, createdAt: notification.createdAt) + let notification = MastodonNotification.insert(into: self.backgroundManagedObjectContext, domain: domain, userID: userID, networkDate: response.networkDate, property: property) + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)", (#file as NSString).lastPathComponent, #line, #function, notification.typeRaw, notification.account.username) } - .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> in - switch result { - case .success: - return response - case .failure(let error): - throw error - } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> in + switch result { + case .success: + return response + case .failure(let error): + throw error } - .eraseToAnyPublisher() } .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index 1cc54add5..b0ab13edb 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -8,7 +8,7 @@ import Combine import Foundation -public extension Mastodon.API.Notifications { +extension Mastodon.API.Notifications { internal static func notificationsEndpointURL(domain: String) -> URL { Mastodon.API.endpointURL(domain: domain).appendingPathComponent("notifications") } @@ -31,7 +31,7 @@ public extension Mastodon.API.Notifications { /// - query: `NotificationsQuery` with query parameters /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - static func getNotifications( + public static func getNotifications( session: URLSession, domain: String, query: Mastodon.API.Notifications.Query, @@ -64,7 +64,7 @@ public extension Mastodon.API.Notifications { /// - notificationID: ID of the notification. /// - authorization: User token /// - Returns: `AnyPublisher` contains `Token` nested in the response - static func getNotification( + public static func getNotification( session: URLSession, domain: String, notificationID: String, @@ -82,18 +82,10 @@ public extension Mastodon.API.Notifications { } .eraseToAnyPublisher() } - - static func allExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { - [.followRequest] - } - - static func mentionsExcludeTypes() -> [Mastodon.Entity.Notification.NotificationType] { - [.follow, .followRequest, .favourite, .reblog, .poll] - } } -public extension Mastodon.API.Notifications { - struct Query: Codable, PagedQueryType, GetQuery { +extension Mastodon.API.Notifications { + public struct Query: PagedQueryType, GetQuery { public let maxID: Mastodon.Entity.Status.ID? public let sinceID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? From 680cf9a827ed03db1c26c7e87df20fbdfeff24ca Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 16 Apr 2021 20:06:36 +0800 Subject: [PATCH 259/400] feat: add blurhash image and update content warning --- .../CoreData.xcdatamodel/contents | 3 +- CoreDataStack/Entity/HomeTimelineIndex.swift | 2 +- CoreDataStack/Entity/Status.swift | 101 +++++---- Localization/app.json | 3 +- Mastodon.xcodeproj/project.pbxproj | 8 + .../xcschemes/xcschememanagement.plist | 2 +- .../xcshareddata/swiftpm/Package.resolved | 20 +- Mastodon/Diffiable/Item/Item.swift | 54 +---- .../Diffiable/Section/StatusSection.swift | 204 +++++++++++++---- Mastodon/Extension/CoreDataStack/Status.swift | 31 +++ Mastodon/Generated/Strings.swift | 8 +- ...Provider+StatusTableViewCellDelegate.swift | 50 +--- .../StatusProvider/StatusProviderFacade.swift | 48 ++++ .../Resources/en.lproj/Localizable.strings | 3 +- ...iedToStatusContentCollectionViewCell.swift | 4 +- .../Compose/View/ComposeToolbarView.swift | 10 +- .../PublicTimelineViewModel+Diffable.swift | 2 +- .../Container/MosaicImageViewContainer.swift | 94 ++++++-- .../Content/ContentWarningOverlayView.swift | 103 ++++++++- .../Scene/Share/View/Content/StatusView.swift | 213 ++++++++++-------- .../TableviewCell/StatusTableViewCell.swift | 37 ++- .../ViewModel/MosaicImageViewModel.swift | 41 +++- Mastodon/State/DocumentStore.swift | 7 +- Mastodon/Vender/BlurHashDecode.swift | 146 ++++++++++++ Mastodon/Vender/BlurHashEncode.swift | 145 ++++++++++++ 25 files changed, 1014 insertions(+), 325 deletions(-) create mode 100644 Mastodon/Vender/BlurHashDecode.swift create mode 100644 Mastodon/Vender/BlurHashEncode.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index eb095669a..3b558f9ff 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -168,6 +168,7 @@ + @@ -215,7 +216,7 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/HomeTimelineIndex.swift b/CoreDataStack/Entity/HomeTimelineIndex.swift index a902f5ce5..10b00aaa0 100644 --- a/CoreDataStack/Entity/HomeTimelineIndex.swift +++ b/CoreDataStack/Entity/HomeTimelineIndex.swift @@ -52,7 +52,7 @@ extension HomeTimelineIndex { } } - // internal method for Toot call + // internal method for status call func softDelete() { deletedAt = Date() } diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index f40f78639..1bb71a1db 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -1,5 +1,5 @@ // -// Toot.swift +// Status.swift // CoreDataStack // // Created by MainasuK Cirno on 2021/1/27. @@ -62,11 +62,13 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? + @NSManaged public private(set) var revealedAt: Date? } -public extension Status { +extension Status { + @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property, author: MastodonUser, @@ -84,81 +86,81 @@ public extension Status { bookmarkedBy: MastodonUser?, pinnedBy: MastodonUser? ) -> Status { - let toot: Status = context.insertObject() + let status: Status = context.insertObject() - toot.identifier = property.identifier - toot.domain = property.domain + status.identifier = property.identifier + status.domain = property.domain - toot.id = property.id - toot.uri = property.uri - toot.createdAt = property.createdAt - toot.content = property.content + status.id = property.id + status.uri = property.uri + status.createdAt = property.createdAt + status.content = property.content - toot.visibility = property.visibility - toot.sensitive = property.sensitive - toot.spoilerText = property.spoilerText - toot.application = application + status.visibility = property.visibility + status.sensitive = property.sensitive + status.spoilerText = property.spoilerText + status.application = application - toot.reblogsCount = property.reblogsCount - toot.favouritesCount = property.favouritesCount - toot.repliesCount = property.repliesCount + status.reblogsCount = property.reblogsCount + status.favouritesCount = property.favouritesCount + status.repliesCount = property.repliesCount - toot.url = property.url - toot.inReplyToID = property.inReplyToID - toot.inReplyToAccountID = property.inReplyToAccountID + status.url = property.url + status.inReplyToID = property.inReplyToID + status.inReplyToAccountID = property.inReplyToAccountID - toot.language = property.language - toot.text = property.text + status.language = property.language + status.text = property.text - toot.author = author - toot.reblog = reblog + status.author = author + status.reblog = reblog - toot.pinnedBy = pinnedBy - toot.poll = poll + status.pinnedBy = pinnedBy + status.poll = poll if let mentions = mentions { - toot.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) + status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } if let emojis = emojis { - toot.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) + status.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) } if let tags = tags { - toot.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) + status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } if let mediaAttachments = mediaAttachments { - toot.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) + status.mutableSetValue(forKey: #keyPath(Status.mediaAttachments)).addObjects(from: mediaAttachments) } if let favouritedBy = favouritedBy { - toot.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) + status.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(favouritedBy) } if let rebloggedBy = rebloggedBy { - toot.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) + status.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(rebloggedBy) } if let mutedBy = mutedBy { - toot.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) + status.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mutedBy) } if let bookmarkedBy = bookmarkedBy { - toot.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) + status.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(bookmarkedBy) } - toot.updatedAt = property.networkDate + status.updatedAt = property.networkDate - return toot + return status } - func update(reblogsCount: NSNumber) { + public func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount } } - func update(favouritesCount: NSNumber) { + public func update(favouritesCount: NSNumber) { if self.favouritesCount.intValue != favouritesCount.intValue { self.favouritesCount = favouritesCount } } - func update(repliesCount: NSNumber?) { + public func update(repliesCount: NSNumber?) { guard let count = repliesCount else { return } @@ -167,13 +169,13 @@ public extension Status { } } - func update(replyTo: Status?) { + public func update(replyTo: Status?) { if self.replyTo != replyTo { self.replyTo = replyTo } } - func update(liked: Bool, by mastodonUser: MastodonUser) { + public func update(liked: Bool, by mastodonUser: MastodonUser) { if liked { if !(self.favouritedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.favouritedBy)).add(mastodonUser) @@ -185,7 +187,7 @@ public extension Status { } } - func update(reblogged: Bool, by mastodonUser: MastodonUser) { + public func update(reblogged: Bool, by mastodonUser: MastodonUser) { if reblogged { if !(self.rebloggedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.rebloggedBy)).add(mastodonUser) @@ -197,7 +199,7 @@ public extension Status { } } - func update(muted: Bool, by mastodonUser: MastodonUser) { + public func update(muted: Bool, by mastodonUser: MastodonUser) { if muted { if !(self.mutedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.mutedBy)).add(mastodonUser) @@ -209,7 +211,7 @@ public extension Status { } } - func update(bookmarked: Bool, by mastodonUser: MastodonUser) { + public func update(bookmarked: Bool, by mastodonUser: MastodonUser) { if bookmarked { if !(self.bookmarkedBy ?? Set()).contains(mastodonUser) { self.mutableSetValue(forKey: #keyPath(Status.bookmarkedBy)).add(mastodonUser) @@ -221,14 +223,18 @@ public extension Status { } } - func didUpdate(at networkDate: Date) { + public func update(isReveal: Bool) { + revealedAt = isReveal ? Date() : nil + } + + public func didUpdate(at networkDate: Date) { self.updatedAt = networkDate } } -public extension Status { - struct Property { +extension Status { + public struct Property { public let identifier: ID public let domain: String @@ -337,4 +343,5 @@ extension Status { public static func deleted() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } + } diff --git a/Localization/app.json b/Localization/app.json index 5d8ad2645..e3ae30e9e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -52,7 +52,8 @@ "user_reblogged": "%s reblogged", "user_replied_to": "Replied to %s", "show_post": "Show Post", - "status_content_warning": "content warning", + "content_warning": "content warning", + "content_warning_text": "cw: %s", "media_content_warning": "Tap to reveal that may be sensitive", "poll": { "vote": "Vote", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f6bf54a2b..ff243e8ba 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -207,6 +207,8 @@ DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */; }; DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = DB5086B725CC0D6400C2C187 /* Kingfisher */; }; DB5086BE25CC0D9900C2C187 /* SplashPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */; }; + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D170262832380062B7A1 /* BlurHashDecode.swift */; }; + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; }; DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; @@ -605,6 +607,8 @@ DB5086A425CC0B7000C2C187 /* AvatarBarButtonItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarBarButtonItem.swift; sourceTree = ""; }; DB5086AA25CC0BBB00C2C187 /* AvatarConfigurableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AvatarConfigurableView.swift; sourceTree = ""; }; DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SplashPreference.swift; sourceTree = ""; }; + DB51D170262832380062B7A1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; + DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; @@ -977,6 +981,8 @@ DB2B3AE825E38850007045F9 /* UIViewPreview.swift */, DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */, DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, + DB51D170262832380062B7A1 /* BlurHashDecode.swift */, + DB51D171262832380062B7A1 /* BlurHashEncode.swift */, ); path = Vender; sourceTree = ""; @@ -2461,6 +2467,7 @@ 0F2021FB2613262F000C64BF /* HashtagTimelineViewController.swift in Sources */, DBCC3B30261440A50045B23D /* UITabBarController.swift in Sources */, DB8190C62601FF0400020C08 /* AttachmentContainerView.swift in Sources */, + DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */, DB5086AB25CC0BBB00C2C187 /* AvatarConfigurableView.swift in Sources */, DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, @@ -2484,6 +2491,7 @@ 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, + DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index fd1ce69a1..c25eac1fa 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 20 + 11 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3bd82fce8..741947371 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/Alamofire/Alamofire.git", "state": { "branch": null, - "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", - "version": "5.4.1" + "revision": "4d19ad82f80cc71ff829b941ded114c56f4f604c", + "version": "5.4.2" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/Alamofire/AlamofireImage.git", "state": { "branch": null, - "revision": "3e8edbeb75227f8542aa87f90240cf0424d6362f", - "version": "4.1.0" + "revision": "98cbb00ce0ec5fc8e52a5b50a6bfc08d3e5aee10", + "version": "4.2.0" } }, { @@ -51,8 +51,8 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "daebf8ddf974164d1b9a050c8231e263f3106b09", - "version": "6.1.0" + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "version": "6.2.1" } }, { @@ -87,8 +87,8 @@ "repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON.git", "state": { "branch": null, - "revision": "2b6054efa051565954e1d2b9da831680026cd768", - "version": "5.0.0" + "revision": "b3dcd7dbd0d488e1a7077cb33b00f2083e382f07", + "version": "5.0.1" } }, { @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/uias/Tabman", "state": { "branch": null, - "revision": "bce2c87659c0ed868e6ef0aa1e05a330e202533f", - "version": "2.11.0" + "revision": "f43489cdd743ba7ad86a422ebb5fcbf34e333df4", + "version": "2.11.1" } }, { diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index da3455201..cb01ccdcf 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -5,6 +5,7 @@ // Created by sxiaojian on 2021/1/27. // +import Combine import CoreData import CoreDataStack import Foundation @@ -33,59 +34,18 @@ enum Item { case emptyStateHeader(attribute: EmptyStateHeaderAttribute) } -protocol StatusContentWarningAttribute { - var isStatusTextSensitive: Bool? { get set } - var isStatusSensitive: Bool? { get set } -} - extension Item { - class StatusAttribute: StatusContentWarningAttribute { - var isStatusTextSensitive: Bool? - var isStatusSensitive: Bool? + class StatusAttribute { var isSeparatorLineHidden: Bool + + let isImageLoaded = CurrentValueSubject(false) + let isMediaRevealing = CurrentValueSubject(false) - init( - isStatusTextSensitive: Bool? = nil, - isStatusSensitive: Bool? = nil, - isSeparatorLineHidden: Bool = false - ) { - self.isStatusTextSensitive = isStatusTextSensitive - self.isStatusSensitive = isStatusSensitive + init(isSeparatorLineHidden: Bool = false) { self.isSeparatorLineHidden = isSeparatorLineHidden } - - // delay attribute init - func setupForStatus(status: Status) { - if isStatusTextSensitive == nil { - isStatusTextSensitive = { - guard let spoilerText = status.spoilerText, !spoilerText.isEmpty else { return false } - return true - }() - } - - if isStatusSensitive == nil { - isStatusSensitive = status.sensitive - } - } } - -// class LeafAttribute { -// let identifier = UUID() -// let statusID: Status.ID -// var level: Int = 0 -// var hasReply: Bool = true -// -// init( -// statusID: Status.ID, -// level: Int, -// hasReply: Bool = true -// ) { -// self.statusID = statusID -// self.level = level -// self.hasReply = hasReply -// } -// } - + class EmptyStateHeaderAttribute: Hashable { let id = UUID() let reason: Reason diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 36d4853a8..f2b3059c9 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -134,10 +134,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { - // setup attribute - statusItemAttribute.setupForStatus(status: status.reblog ?? status) - + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) @@ -172,19 +169,6 @@ extension StatusSection { // set text cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) - // set status text content warning - let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false - let spoilerText = (status.reblog ?? status).spoilerText ?? "" - cell.statusView.isStatusTextSensitive = isStatusTextSensitive - cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) - cell.statusView.contentWarningTitle.text = { - if spoilerText.isEmpty { - return L10n.Common.Controls.Status.statusContentWarning - } else { - return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" - } - }() - // prepare media attachments let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } @@ -208,30 +192,68 @@ extension StatusSection { }() return CGSize(width: maxWidth, height: maxWidth * scale) }() - if mosiacImageViewModel.metas.count == 1 { - let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + let blurhashImageCache = dependency.context.documentStore.blurhashImageCache + let mosaics: [MosaicImageViewContainer.ConfigurableMosaic] = { + if mosiacImageViewModel.metas.count == 1 { + let meta = mosiacImageViewModel.metas[0] + let mosaic = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) + return [mosaic] + } else { + let mosaics = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) + return mosaics + } + }() + for (i, mosiac) in mosaics.enumerated() { + let (imageView, blurhashOverlayImageView) = mosiac + let meta = mosiacImageViewModel.metas[i] + let blurhashImageDataKey = meta.url.absoluteString as NSString + if let blurhashImageData = blurhashImageCache.object(forKey: meta.url.absoluteString as NSString), + let image = UIImage(data: blurhashImageData as Data) { + blurhashOverlayImageView.image = image + } else { + meta.blurhashImagePublisher() + .receive(on: DispatchQueue.main) + .sink { image in + blurhashOverlayImageView.image = image + image?.pngData().flatMap { + blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) + } + } + .store(in: &cell.disposeBag) + } imageView.af.setImage( withURL: meta.url, placeholderImage: UIImage.placeholder(color: .systemFill), imageTransition: .crossDissolve(0.2) - ) - } else { - let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) - for (i, imageView) in imageViews.enumerated() { - let meta = mosiacImageViewModel.metas[i] - imageView.af.setImage( - withURL: meta.url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) + ) { response in + switch response.result { + case .success: + statusItemAttribute.isImageLoaded.value = true + case .failure: + break + } } + Publishers.CombineLatest( + statusItemAttribute.isImageLoaded, + statusItemAttribute.isMediaRevealing + ) + .receive(on: DispatchQueue.main) + .sink { isImageLoaded, isMediaRevealing in + guard isImageLoaded else { + blurhashOverlayImageView.alpha = 1 + blurhashOverlayImageView.isHidden = false + return + } + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + } + animator.startAnimation() + } + .store(in: &cell.disposeBag) } cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive // set audio if let audioAttachment = mediaAttachments.filter({ $0.type == .audio }).first { @@ -253,10 +275,6 @@ extension StatusSection { return CGSize(width: maxWidth, height: maxWidth * scale) }() - cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive - if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { @@ -294,6 +312,34 @@ extension StatusSection { cell.statusView.playerContainerView.playerViewController.player?.pause() cell.statusView.playerContainerView.playerViewController.player = nil } + + // set text content warning + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: false + ) + // observe model change + ManagedObjectObserver.observe(object: status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak dependency] change in + guard let dependency = dependency else { return } + guard case .update(let object) = change.changeType, + let status = object as? Status else { return } + StatusSection.configureContentWarningOverlay( + statusView: cell.statusView, + status: status, + attribute: statusItemAttribute, + documentStore: dependency.context.documentStore, + animated: true + ) + } + .store(in: &cell.disposeBag) + // set poll let poll = (status.reblog ?? status).poll StatusSection.configurePoll( @@ -352,6 +398,88 @@ extension StatusSection { .store(in: &cell.disposeBag) } + static func configureContentWarningOverlay( + statusView: StatusView, + status: Status, + attribute: Item.StatusAttribute, + documentStore: DocumentStore, + animated: Bool + ) { + statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = { + let spoilerText = status.spoilerText ?? "" + if spoilerText.isEmpty { + return L10n.Common.Controls.Status.contentWarning + } else { + return L10n.Common.Controls.Status.contentWarningText(spoilerText) + } + }() + let appStartUpTimestamp = documentStore.appStartUpTimestamp + + switch (status.reblog ?? status).sensitiveType { + case .none: + statusView.revealContentWarningButton.isHidden = true + statusView.contentWarningOverlayView.isHidden = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + case .all: + statusView.revealContentWarningButton.isHidden = false + statusView.contentWarningOverlayView.isHidden = false + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + statusView.updateRevealContentWarningButton(isRevealing: true) + statusView.updateContentWarningDisplay(isHidden: true, animated: animated) + attribute.isMediaRevealing.value = true + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.updateContentWarningDisplay(isHidden: false, animated: animated) + attribute.isMediaRevealing.value = false + } + case .media(let isSensitive): + if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil { + documentStore.defaultRevealStatusDict[status.id] = true + } + statusView.revealContentWarningButton.isHidden = false + statusView.contentWarningOverlayView.isHidden = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + + func updateContentOverlay() { + let needsReveal: Bool = { + if documentStore.defaultRevealStatusDict[status.id] == true { + return true + } + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + return true + } + + return false + }() + attribute.isMediaRevealing.value = needsReveal + if needsReveal { + statusView.updateRevealContentWarningButton(isRevealing: true) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = nil + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 + statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = false + } else { + statusView.updateRevealContentWarningButton(isRevealing: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 + statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = true + } + } + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { + updateContentOverlay() + } completion: { _ in + // do nothing + } + } else { + updateContentOverlay() + } + } + } + static func configureThreadMeta( cell: StatusTableViewCell, status: Status diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index cf4f8a1bd..880be6fa3 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -32,3 +32,34 @@ extension Status.Property { ) } } + +extension Status { + + enum SensitiveType { + case none + case all + case media(isSensitive: Bool) + } + + var sensitiveType: SensitiveType { + let spoilerText = self.spoilerText ?? "" + + // cast .all sensitive when has spoiter text + if !spoilerText.isEmpty { + return .all + } + + if let firstAttachment = mediaAttachments?.first { + // cast .media when has non audio media + if firstAttachment.type != .audio { + return .media(isSensitive: sensitive) + } else { + return .none + } + } + + // not sensitive + return .none + } + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6eed41a29..5d486b5f6 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -138,12 +138,16 @@ internal enum L10n { } } internal enum Status { + /// content warning + internal static let contentWarning = L10n.tr("Localizable", "Common.Controls.Status.ContentWarning") + /// cw: %@ + internal static func contentWarningText(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Status.ContentWarningText", String(describing: p1)) + } /// Tap to reveal that may be sensitive internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") /// Show Post internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") - /// content warning - internal static let statusContentWarning = L10n.tr("Localizable", "Common.Controls.Status.StatusContentWarning") /// %@ reblogged internal static func userReblogged(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 25322e216..8d9687777 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -28,6 +28,14 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.responseToStatusActiveLabelAction(provider: self, cell: cell, activeLabel: activeLabel, didTapEntity: entity) } + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) + } + + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) + } + } // MARK: - ActionToolbarContainerDelegate @@ -45,25 +53,6 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { StatusProviderFacade.responseToStatusLikeAction(provider: self, cell: cell) } - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute), - .status(_, let attribute), - .root(_, let attribute), - .reply(_, let attribute), - .leaf(_, let attribute): - attribute.isStatusTextSensitive = false - default: - return - } - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - diffableDataSource.apply(snapshot) - } - } // MARK: - MosciaImageViewContainerDelegate @@ -83,28 +72,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - guard let diffableDataSource = self.tableViewDiffableDataSource else { return } - guard let item = item(for: cell, indexPath: nil) else { return } - - switch item { - case .homeTimelineIndex(_, let attribute), - .status(_, let attribute), - .root(_, let attribute), - .reply(_, let attribute), - .leaf(_, let attribute): - attribute.isStatusSensitive = false - default: - return - } - contentWarningOverlayView.isUserInteractionEnabled = false - var snapshot = diffableDataSource.snapshot() - snapshot.reloadItems([item]) - UIView.animate(withDuration: 0.33) { - contentWarningOverlayView.blurVisualEffectView.effect = nil - contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 - } completion: { _ in - diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 0e26614c5..17d2fbe4e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -415,6 +415,54 @@ extension StatusProviderFacade { } +extension StatusProviderFacade { + + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { + _responseToStatusContentWarningRevealAction( + provider: provider, + status: provider.status(for: cell, indexPath: nil) + ) + } + + private static func _responseToStatusContentWarningRevealAction(provider: StatusProvider, status: Future) { + status + .compactMap { [weak provider] status -> AnyPublisher? in + guard let provider = provider else { return nil } + guard let _status = status else { return nil } + return provider.context.managedObjectContext.performChanges { + guard let status = provider.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } + let appStartUpTimestamp = provider.context.documentStore.appStartUpTimestamp + let isRevealing: Bool = { + if provider.context.documentStore.defaultRevealStatusDict[status.id] == true { + return true + } + if status.reblog.flatMap({ provider.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { + return true + } + if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { + return true + } + + return false + }() + // toggle reveal + provider.context.documentStore.defaultRevealStatusDict[status.id] = false + status.update(isReveal: !isRevealing) + status.reblog?.update(isReveal: !isRevealing) + } + .map { result in + return status + } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + +} + extension StatusProviderFacade { enum Target { case primary // original status diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index c35d6e633..46275884f 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -46,6 +46,8 @@ Please check your internet connection."; "Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.Unmute" = "Unmute"; "Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ContentWarningText" = "cw: %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; "Common.Controls.Status.Poll.Closed" = "Closed"; "Common.Controls.Status.Poll.TimeLeft" = "%@ left"; @@ -55,7 +57,6 @@ Please check your internet connection."; "Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; -"Common.Controls.Status.StatusContentWarning" = "content warning"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 95e9b4f1a..506d61391 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -19,8 +19,7 @@ final class ComposeRepliedToStatusContentCollectionViewCell: UICollectionViewCel override func prepareForReuse() { super.prepareForReuse() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) disposeBag.removeAll() } @@ -45,7 +44,6 @@ extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { backgroundColor = .clear - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 99288a5e0..2940217e5 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -284,13 +284,13 @@ struct ComposeToolbarView_Previews: PreviewProvider { static var previews: some View { UIViewPreview(width: 375) { - let tootbarView = ComposeToolbarView() - tootbarView.translatesAutoresizingMaskIntoConstraints = false + let toolbarView = ComposeToolbarView() + toolbarView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - tootbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), - tootbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), + toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh), + toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh), ]) - return tootbarView + return toolbarView } .previewLayout(.fixed(width: 375, height: 100)) } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift index 3ca407caa..27336dc58 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewModel+Diffable.swift @@ -64,7 +64,7 @@ extension PublicTimelineViewModel: NSFetchedResultsControllerDelegate { guard let spoilerText = targetStatus.spoilerText, !spoilerText.isEmpty else { return false } return true }() - let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute(isStatusTextSensitive: isStatusTextSensitive, isStatusSensitive: targetStatus.sensitive) + let attribute = oldSnapshotAttributeDict[status.objectID] ?? Item.StatusAttribute() items.append(Item.status(objectID: status.objectID, attribute: attribute)) if statusIDsWhichHasGap.contains(status.id) { items.append(Item.publicMiddleLoader(statusID: status.id)) diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index b3c03a46b..641050bc2 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -34,9 +34,11 @@ final class MosaicImageViewContainer: UIView { } } } + var blurhashOverlayImageViews: [UIImageView] = [] let contentWarningOverlayView: ContentWarningOverlayView = { let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.configure(style: .visualEffectView) return contentWarningOverlayView }() @@ -96,11 +98,14 @@ extension MosaicImageViewContainer { contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 contentWarningOverlayView.isUserInteractionEnabled = true imageViews = [] + blurhashOverlayImageViews = [] container.spacing = 1 } - func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> UIImageView { + typealias ConfigurableMosaic = (imageView: UIImageView, blurhashOverlayImageView: UIImageView) + + func setupImageView(aspectRatio: CGSize, maxSize: CGSize) -> ConfigurableMosaic { reset() let contentView = UIView() @@ -130,6 +135,21 @@ extension MosaicImageViewContainer { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true + let blurhashOverlayImageView = UIImageView() + blurhashOverlayImageView.layer.masksToBounds = true + blurhashOverlayImageView.layer.cornerRadius = ContentWarningOverlayView.cornerRadius + blurhashOverlayImageView.layer.cornerCurve = .continuous + blurhashOverlayImageView.contentMode = .scaleAspectFill + blurhashOverlayImageViews.append(blurhashOverlayImageView) + blurhashOverlayImageView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(blurhashOverlayImageView) + NSLayoutConstraint.activate([ + blurhashOverlayImageView.topAnchor.constraint(equalTo: imageView.topAnchor), + blurhashOverlayImageView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor), + blurhashOverlayImageView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), + blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), + ]) + addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), @@ -137,11 +157,11 @@ extension MosaicImageViewContainer { contentWarningOverlayView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor), contentWarningOverlayView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) - - return imageView + + return (imageView, blurhashOverlayImageView) } - func setupImageViews(count: Int, maxHeight: CGFloat) -> [UIImageView] { + func setupImageViews(count: Int, maxHeight: CGFloat) -> [ConfigurableMosaic] { reset() guard count > 1 else { return [] @@ -161,16 +181,25 @@ extension MosaicImageViewContainer { container.addArrangedSubview(contentRightStackView) var imageViews: [UIImageView] = [] + var blurhashOverlayImageViews: [UIImageView] = [] for _ in 0..? var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! @@ -115,25 +116,14 @@ final class StatusView: UIView { return label }() - let statusContainerStackView = UIStackView() - let statusTextContainerView = UIView() - let statusContentWarningContainerStackView = UIStackView() - var statusContentWarningContainerStackViewBottomLayoutConstraint: NSLayoutConstraint! - - let contentWarningTitle: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) - label.textColor = Asset.Colors.Label.primary.color - label.text = L10n.Common.Controls.Status.statusContentWarning - return label - }() - let contentWarningActionButton: UIButton = { - let button = UIButton() - button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .medium)) - button.setTitleColor(Asset.Colors.Label.highlight.color, for: .normal) - button.setTitle(L10n.Common.Controls.Status.showPost, for: .normal) + let revealContentWarningButton: UIButton = { + let button = HighlightDimmableButton() + button.setImage(UIImage(systemName: "eye", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .medium)), for: .normal) + button.tintColor = Asset.Colors.Button.normal.color return button }() + + let statusContainerStackView = UIStackView() let statusMosaicImageViewContainer = MosaicImageViewContainer() let pollTableView: PollTableView = { @@ -179,11 +169,11 @@ final class StatusView: UIView { }() // do not use visual effect view due to we blur text only without background - let contentWarningBlurContentImageView: UIImageView = { - let imageView = UIImageView() - imageView.backgroundColor = Asset.Colors.Background.systemBackground.color - imageView.layer.masksToBounds = false - return imageView + let contentWarningOverlayView: ContentWarningOverlayView = { + let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.layer.masksToBounds = false + contentWarningOverlayView.configure(style: .blurContentImageView) + return contentWarningOverlayView }() let playerContainerView = PlayerContainerView() @@ -250,11 +240,12 @@ extension StatusView { headerContainerStackView.addArrangedSubview(headerInfoLabel) headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - // author container: [avatar | author meta container] + // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal authorContainerStackView.spacing = StatusView.avatarToLabelSpacing + authorContainerStackView.distribution = .fill // avatar avatarView.translatesAutoresizingMaskIntoConstraints = false @@ -310,45 +301,44 @@ extension StatusView { authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) subtitleContainerStackView.axis = .horizontal subtitleContainerStackView.addArrangedSubview(usernameLabel) + + // reveal button + authorContainerStackView.addArrangedSubview(revealContentWarningButton) + revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) - // status container: [status | image / video | audio | poll | poll status] + // status container: [status | image / video | audio | poll | poll status] (overlay with content warning) containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical statusContainerStackView.spacing = 10 - statusContainerStackView.addArrangedSubview(statusTextContainerView) - statusTextContainerView.setContentCompressionResistancePriority(.required - 2, for: .vertical) - activeTextLabel.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(activeTextLabel) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - activeTextLabel.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - activeTextLabel.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: activeTextLabel.bottomAnchor), - ]) - activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - contentWarningBlurContentImageView.translatesAutoresizingMaskIntoConstraints = false - statusTextContainerView.addSubview(contentWarningBlurContentImageView) - NSLayoutConstraint.activate([ - activeTextLabel.topAnchor.constraint(equalTo: contentWarningBlurContentImageView.topAnchor, constant: StatusView.contentWarningBlurRadius), - activeTextLabel.leadingAnchor.constraint(equalTo: contentWarningBlurContentImageView.leadingAnchor, constant: StatusView.contentWarningBlurRadius), - - ]) - statusContentWarningContainerStackView.translatesAutoresizingMaskIntoConstraints = false - statusContentWarningContainerStackView.axis = .vertical - statusContentWarningContainerStackView.distribution = .fill - statusContentWarningContainerStackView.alignment = .center - statusTextContainerView.addSubview(statusContentWarningContainerStackView) - statusContentWarningContainerStackViewBottomLayoutConstraint = statusTextContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: statusContentWarningContainerStackView.bottomAnchor) - NSLayoutConstraint.activate([ - statusContentWarningContainerStackView.topAnchor.constraint(equalTo: statusTextContainerView.topAnchor), - statusContentWarningContainerStackView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - statusContentWarningContainerStackView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - statusContentWarningContainerStackViewBottomLayoutConstraint, - ]) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningTitle) - statusContentWarningContainerStackView.addArrangedSubview(contentWarningActionButton) + // content warning overlay + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), + statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), + // only layout to top-left corner and draw image to fit size + ]) + // avoid overlay clip author view + containerStackView.bringSubviewToFront(authorContainerStackView) + + // status + statusContainerStackView.addArrangedSubview(activeTextLabel) + activeTextLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + // image statusContainerStackView.addArrangedSubview(statusMosaicImageViewContainer) + + // audio + audioView.translatesAutoresizingMaskIntoConstraints = false + statusContainerStackView.addArrangedSubview(audioView) + NSLayoutConstraint.activate([ + audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) + ]) + + // video & gifv + statusContainerStackView.addArrangedSubview(playerContainerView) + pollTableView.translatesAutoresizingMaskIntoConstraints = false statusContainerStackView.addArrangedSubview(pollTableView) pollTableViewHeightLaoutConstraint = pollTableView.heightAnchor.constraint(equalToConstant: 44.0).priority(.required - 1) @@ -376,17 +366,6 @@ extension StatusView { pollCountdownLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) pollVoteButton.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) - // audio - audioView.translatesAutoresizingMaskIntoConstraints = false - statusContainerStackView.addArrangedSubview(audioView) - NSLayoutConstraint.activate([ - audioView.leadingAnchor.constraint(equalTo: statusTextContainerView.leadingAnchor), - audioView.trailingAnchor.constraint(equalTo: statusTextContainerView.trailingAnchor), - audioView.heightAnchor.constraint(equalToConstant: 44).priority(.defaultHigh) - ]) - // video gif - statusContainerStackView.addArrangedSubview(playerContainerView) - // action toolbar container containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) @@ -399,12 +378,11 @@ extension StatusView { playerContainerView.isHidden = true avatarStackedContainerButton.isHidden = true - contentWarningBlurContentImageView.isHidden = true - statusContentWarningContainerStackView.isHidden = true - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = false + contentWarningOverlayView.isHidden = true activeTextLabel.delegate = self playerContainerView.delegate = self + contentWarningOverlayView.delegate = self headerInfoLabelTapGestureRecognizer.addTarget(self, action: #selector(StatusView.headerInfoLabelTapGestureRecognizerHandler(_:))) headerInfoLabel.isUserInteractionEnabled = true @@ -412,7 +390,7 @@ extension StatusView { avatarButton.addTarget(self, action: #selector(StatusView.avatarButtonDidPressed(_:)), for: .touchUpInside) avatarStackedContainerButton.addTarget(self, action: #selector(StatusView.avatarStackedContainerButtonDidPressed(_:)), for: .touchUpInside) - contentWarningActionButton.addTarget(self, action: #selector(StatusView.contentWarningActionButtonPressed(_:)), for: .touchUpInside) + revealContentWarningButton.addTarget(self, action: #selector(StatusView.revealContentWarningButtonDidPressed(_:)), for: .touchUpInside) pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonPressed(_:)), for: .touchUpInside) } @@ -420,30 +398,64 @@ extension StatusView { extension StatusView { - func cleanUpContentWarning() { - contentWarningBlurContentImageView.image = nil + private func cleanUpContentWarning() { + contentWarningOverlayView.blurContentImageView.image = nil } func drawContentWarningImageView() { - guard activeTextLabel.frame != .zero, - isStatusTextSensitive, - let text = activeTextLabel.text, !text.isEmpty else { - cleanUpContentWarning() + guard window != nil else { return } - let image = UIGraphicsImageRenderer(size: activeTextLabel.frame.size).image { context in - activeTextLabel.draw(activeTextLabel.bounds) + guard needsDrawContentOverlay, statusContainerStackView.frame != .zero else { + cleanUpContentWarning() + return + } + + let format = UIGraphicsImageRendererFormat() + format.opaque = false + let image = UIGraphicsImageRenderer(size: statusContainerStackView.frame.size, format: format).image { context in + statusContainerStackView.drawHierarchy(in: statusContainerStackView.bounds, afterScreenUpdates: true) + + // always draw the blurhash image + statusMosaicImageViewContainer.blurhashOverlayImageViews.forEach { imageView in + guard let image = imageView.image else { return } + guard let frame = imageView.superview?.convert(imageView.frame, to: statusContainerStackView) else { return } + image.draw(in: frame) + } } .blur(radius: StatusView.contentWarningBlurRadius) - contentWarningBlurContentImageView.contentScaleFactor = traitCollection.displayScale - contentWarningBlurContentImageView.image = image + contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale + contentWarningOverlayView.blurContentImageView.image = image } - func updateContentWarningDisplay(isHidden: Bool) { - contentWarningBlurContentImageView.isHidden = isHidden - statusContentWarningContainerStackView.isHidden = isHidden - statusContentWarningContainerStackViewBottomLayoutConstraint.isActive = !isHidden + func updateContentWarningDisplay(isHidden: Bool, animated: Bool) { + needsDrawContentOverlay = !isHidden + if animated { + UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in + guard let self = self else { return } + self.contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } completion: { _ in + // do nothing + } + } else { + contentWarningOverlayView.alpha = isHidden ? 0 : 1 + } + + if !isHidden { + drawContentWarningImageView() + } + } + + func updateRevealContentWarningButton(isRevealing: Bool) { + if !isRevealing { + let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") + revealContentWarningButton.setImage(image, for: .normal) + } else { + let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye.slash")! : UIImage(systemName: "eye.slash.fill") + revealContentWarningButton.setImage(image, for: .normal) + } + // TODO: a11y } } @@ -465,9 +477,9 @@ extension StatusView { delegate?.statusView(self, avatarButtonDidPressed: sender) } - @objc private func contentWarningActionButtonPressed(_ sender: UIButton) { + @objc private func revealContentWarningButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.statusView(self, contentWarningActionButtonPressed: sender) + delegate?.statusView(self, revealContentWarningButtonDidPressed: sender) } @objc private func pollVoteButtonPressed(_ sender: UIButton) { @@ -485,6 +497,15 @@ extension StatusView: ActiveLabelDelegate { } } +// MARK: - ContentWarningOverlayViewDelegate +extension StatusView: ContentWarningOverlayViewDelegate { + func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + assert(contentWarningOverlayView === self.contentWarningOverlayView) + delegate?.statusView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + +} + // MARK: - PlayerContainerViewDelegate extension StatusView: PlayerContainerViewDelegate { func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { @@ -554,13 +575,13 @@ struct StatusView_Previews: PreviewProvider { ) statusView.headerContainerStackView.isHidden = false let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic imageView.image = images[i] } statusView.statusMosaicImageViewContainer.isHidden = false statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true - statusView.isStatusTextSensitive = false return statusView } .previewLayout(.fixed(width: 375, height: 380)) @@ -574,14 +595,14 @@ struct StatusView_Previews: PreviewProvider { ) ) statusView.headerContainerStackView.isHidden = false - statusView.isStatusTextSensitive = true statusView.setNeedsLayout() statusView.layoutIfNeeded() + statusView.updateContentWarningDisplay(isHidden: false, animated: false) statusView.drawContentWarningImageView() - statusView.updateContentWarningDisplay(isHidden: false) let images = MosaicImageView_Previews.images - let imageViews = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) - for (i, imageView) in imageViews.enumerated() { + let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) + for (i, mosaic) in mosaics.enumerated() { + let (imageView, _) = mosaic imageView.image = images[i] } statusView.statusMosaicImageViewContainer.isHidden = false diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index afa044b67..d219daddd 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -22,7 +22,8 @@ protocol StatusTableViewCellDelegate: class { func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, avatarButtonDidPressed button: UIButton) - func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningActionButtonPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) + func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, pollVoteButtonPressed button: UIButton) func statusTableViewCell(_ cell: StatusTableViewCell, statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) @@ -55,6 +56,7 @@ final class StatusTableViewCell: UITableViewCell { var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() + private var selectionBackgroundViewObservation: NSKeyValueObservation? let statusView = StatusView() let threadMetaStackView = UIStackView() @@ -70,8 +72,7 @@ final class StatusTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() selectionStyle = .default - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true @@ -92,8 +93,9 @@ final class StatusTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() + DispatchQueue.main.async { - self.statusView.drawContentWarningImageView() + self.statusView.drawContentWarningImageView() } } @@ -103,7 +105,6 @@ extension StatusTableViewCell { private func _init() { backgroundColor = Asset.Colors.Background.systemBackground.color - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) @@ -150,9 +151,22 @@ extension StatusTableViewCell { resetSeparatorLineLayout() } + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + } extension StatusTableViewCell { + private func resetSeparatorLineLayout() { separatorLineToEdgeLeadingLayoutConstraint.isActive = false separatorLineToEdgeTrailingLayoutConstraint.isActive = false @@ -181,6 +195,11 @@ extension StatusTableViewCell { } } } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } } // MARK: - UITableViewDelegate @@ -270,8 +289,12 @@ extension StatusTableViewCell: StatusViewDelegate { delegate?.statusTableViewCell(self, statusView: statusView, avatarButtonDidPressed: button) } - func statusView(_ statusView: StatusView, contentWarningActionButtonPressed button: UIButton) { - delegate?.statusTableViewCell(self, statusView: statusView, contentWarningActionButtonPressed: button) + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + delegate?.statusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.statusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index ce92ccb77..26e426add 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -6,6 +6,7 @@ // import UIKit +import Combine import CoreDataStack struct MosaicImageViewModel { @@ -24,7 +25,12 @@ struct MosaicImageViewModel { let url = URL(string: urlString) else { continue } - metas.append(MosaicMeta(url: url, size: CGSize(width: width, height: height))) + let mosaicMeta = MosaicMeta( + url: url, + size: CGSize(width: width, height: height), + blurhash: element.blurhash + ) + metas.append(mosaicMeta) } self.metas = metas } @@ -32,6 +38,39 @@ struct MosaicImageViewModel { } struct MosaicMeta { + static let edgeMaxLength: CGFloat = 20 + let url: URL let size: CGSize + let blurhash: String? + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent) + + func blurhashImagePublisher() -> AnyPublisher { + return Future { promise in + guard let blurhash = blurhash else { + promise(.success(nil)) + return + } + + let imageSize: CGSize = { + let aspectRadio = size.width / size.height + if size.width > size.height { + let width: CGFloat = MosaicMeta.edgeMaxLength + let height = width / aspectRadio + return CGSize(width: width, height: height) + } else { + let height: CGFloat = MosaicMeta.edgeMaxLength + let width = height * aspectRadio + return CGSize(width: width, height: height) + } + }() + + workingQueue.async { + let image = UIImage(blurHash: blurhash, size: imageSize) + promise(.success(image)) + } + } + .eraseToAnyPublisher() + } } diff --git a/Mastodon/State/DocumentStore.swift b/Mastodon/State/DocumentStore.swift index b39a29245..8b3f88eb7 100644 --- a/Mastodon/State/DocumentStore.swift +++ b/Mastodon/State/DocumentStore.swift @@ -7,5 +7,10 @@ import UIKit import Combine +import MastodonSDK -class DocumentStore: ObservableObject { } +class DocumentStore: ObservableObject { + let blurhashImageCache = NSCache() + let appStartUpTimestamp = Date() + var defaultRevealStatusDict: [Mastodon.Entity.Status.ID: Bool] = [:] +} diff --git a/Mastodon/Vender/BlurHashDecode.swift b/Mastodon/Vender/BlurHashDecode.swift new file mode 100644 index 000000000..7fe3b3985 --- /dev/null +++ b/Mastodon/Vender/BlurHashDecode.swift @@ -0,0 +1,146 @@ +import UIKit + +extension UIImage { + public convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } + + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 + + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 + + guard blurHash.count == 4 + 2 * numX * numY else { return nil } + + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } + + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } + + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) + + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } + + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) else { return nil } + + self.init(cgImage: cgImage) + } +} + +private func decodeDC(_ value: Int) -> (Float, Float, Float) { + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) +} + +private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 + + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) + + return rgb +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +private let decodeCharacters: [String: Int] = { + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict +}() + +extension String { + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } +} + +private extension String { + subscript (offset: Int) -> Character { + return self[index(startIndex, offsetBy: offset)] + } + + subscript (bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start...end] + } + + subscript (bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start.. String? { + let pixelWidth = Int(round(size.width * scale)) + let pixelHeight = Int(round(size.height * scale)) + + let context = CGContext( + data: nil, + width: pixelWidth, + height: pixelHeight, + bitsPerComponent: 8, + bytesPerRow: pixelWidth * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.scaleBy(x: scale, y: -scale) + context.translateBy(x: 0, y: -size.height) + + UIGraphicsPushContext(context) + draw(at: .zero) + UIGraphicsPopContext() + + guard let cgImage = context.makeImage(), + let dataProvider = cgImage.dataProvider, + let data = dataProvider.data, + let pixels = CFDataGetBytePtr(data) else { + assertionFailure("Unexpected error!") + return nil + } + + let width = cgImage.width + let height = cgImage.height + let bytesPerRow = cgImage.bytesPerRow + + var factors: [(Float, Float, Float)] = [] + for y in 0 ..< components.1 { + for x in 0 ..< components.0 { + let normalisation: Float = (x == 0 && y == 0) ? 1 : 2 + let factor = multiplyBasisFunction(pixels: pixels, width: width, height: height, bytesPerRow: bytesPerRow, bytesPerPixel: cgImage.bitsPerPixel / 8, pixelOffset: 0) { + normalisation * cos(Float.pi * Float(x) * $0 / Float(width)) as Float * cos(Float.pi * Float(y) * $1 / Float(height)) as Float + } + factors.append(factor) + } + } + + let dc = factors.first! + let ac = factors.dropFirst() + + var hash = "" + + let sizeFlag = (components.0 - 1) + (components.1 - 1) * 9 + hash += sizeFlag.encode83(length: 1) + + let maximumValue: Float + if ac.count > 0 { + let actualMaximumValue = ac.map({ max(abs($0.0), abs($0.1), abs($0.2)) }).max()! + let quantisedMaximumValue = Int(max(0, min(82, floor(actualMaximumValue * 166 - 0.5)))) + maximumValue = Float(quantisedMaximumValue + 1) / 166 + hash += quantisedMaximumValue.encode83(length: 1) + } else { + maximumValue = 1 + hash += 0.encode83(length: 1) + } + + hash += encodeDC(dc).encode83(length: 4) + + for factor in ac { + hash += encodeAC(factor, maximumValue: maximumValue).encode83(length: 2) + } + + return hash + } + + private func multiplyBasisFunction(pixels: UnsafePointer, width: Int, height: Int, bytesPerRow: Int, bytesPerPixel: Int, pixelOffset: Int, basisFunction: (Float, Float) -> Float) -> (Float, Float, Float) { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 + + let buffer = UnsafeBufferPointer(start: pixels, count: height * bytesPerRow) + + for x in 0 ..< width { + for y in 0 ..< height { + let basis = basisFunction(Float(x), Float(y)) + r += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 0 + y * bytesPerRow]) + g += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 1 + y * bytesPerRow]) + b += basis * sRGBToLinear(buffer[bytesPerPixel * x + pixelOffset + 2 + y * bytesPerRow]) + } + } + + let scale = 1 / Float(width * height) + + return (r * scale, g * scale, b * scale) + } +} + +private func encodeDC(_ value: (Float, Float, Float)) -> Int { + let roundedR = linearTosRGB(value.0) + let roundedG = linearTosRGB(value.1) + let roundedB = linearTosRGB(value.2) + return (roundedR << 16) + (roundedG << 8) + roundedB +} + +private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int { + let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5)))) + let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5)))) + let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5)))) + + return quantR * 19 * 19 + quantG * 19 + quantB +} + +private func signPow(_ value: Float, _ exp: Float) -> Float { + return copysign(pow(abs(value), exp), value) +} + +private func linearTosRGB(_ value: Float) -> Int { + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } + else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } +} + +private func sRGBToLinear(_ value: Type) -> Float { + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } + else { return pow((v + 0.055) / 1.055, 2.4) } +} + +private let encodeCharacters: [String] = { + return "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } +}() + +extension BinaryInteger { + func encode83(length: Int) -> String { + var result = "" + for i in 1 ... length { + let digit = (Int(self) / pow(83, length - i)) % 83 + result += encodeCharacters[Int(digit)] + } + return result + } +} + +private func pow(_ base: Int, _ exponent: Int) -> Int { + return (0 ..< exponent).reduce(1) { value, _ in value * base } +} From e3c6aaf64e073ed489b4a50c0633c98407d95326 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 16 Apr 2021 20:29:08 +0800 Subject: [PATCH 260/400] fix: blurhash image render issue --- .../xcschemes/xcschememanagement.plist | 2 +- Mastodon/Diffiable/Section/StatusSection.swift | 15 ++++++++++----- .../Scene/Share/View/Content/StatusView.swift | 17 +++++++---------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index c25eac1fa..6ec23cf5d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 11 + 10 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index f2b3059c9..fc7b02c21 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -245,11 +245,16 @@ extension StatusSection { return } - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + if isMediaRevealing { + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + blurhashOverlayImageView.alpha = isMediaRevealing ? 0 : 1 + } + animator.startAnimation() + } else { + cell.statusView.drawContentWarningImageView() } - animator.startAnimation() } .store(in: &cell.disposeBag) } @@ -406,7 +411,7 @@ extension StatusSection { animated: Bool ) { statusView.contentWarningOverlayView.blurContentWarningTitleLabel.text = { - let spoilerText = status.spoilerText ?? "" + let spoilerText = (status.reblog ?? status).spoilerText ?? "" if spoilerText.isEmpty { return L10n.Common.Controls.Status.contentWarning } else { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 2b0294fee..a58ad1247 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -416,13 +416,6 @@ extension StatusView { format.opaque = false let image = UIGraphicsImageRenderer(size: statusContainerStackView.frame.size, format: format).image { context in statusContainerStackView.drawHierarchy(in: statusContainerStackView.bounds, afterScreenUpdates: true) - - // always draw the blurhash image - statusMosaicImageViewContainer.blurhashOverlayImageViews.forEach { imageView in - guard let image = imageView.image else { return } - guard let frame = imageView.superview?.convert(imageView.frame, to: statusContainerStackView) else { return } - image.draw(in: frame) - } } .blur(radius: StatusView.contentWarningBlurRadius) contentWarningOverlayView.blurContentImageView.contentScaleFactor = traitCollection.displayScale @@ -431,6 +424,11 @@ extension StatusView { func updateContentWarningDisplay(isHidden: Bool, animated: Bool) { needsDrawContentOverlay = !isHidden + + if !isHidden { + drawContentWarningImageView() + } + if animated { UIView.animate(withDuration: 0.33, delay: 0, options: .curveEaseInOut) { [weak self] in guard let self = self else { return } @@ -442,9 +440,8 @@ extension StatusView { contentWarningOverlayView.alpha = isHidden ? 0 : 1 } - if !isHidden { - drawContentWarningImageView() - } + contentWarningOverlayView.blurContentWarningTitleLabel.isHidden = isHidden + contentWarningOverlayView.blurContentWarningLabel.isHidden = isHidden } func updateRevealContentWarningButton(isRevealing: Bool) { From cfdd2ea6705c1ed07a3fd04895527375040d5387 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 16 Apr 2021 21:55:09 +0800 Subject: [PATCH 261/400] chore: use stackView --- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Extension/UIView+Remove.swift | 18 +++ .../NotificationViewController.swift | 2 +- .../NotificationViewModel+diffable.swift | 4 +- .../NotificationStatusTableViewCell.swift | 130 ++++++++++++------ .../NotificationTableViewCell.swift | 92 +++++++++---- 6 files changed, 176 insertions(+), 74 deletions(-) create mode 100644 Mastodon/Extension/UIView+Remove.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 3244dcd33..19506bb99 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; + 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */; }; 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */; }; 5DDDF1992617447F00311060 /* Mastodon+Entity+Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */; }; 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */; }; @@ -525,6 +526,7 @@ 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; + 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; }; 5DDDF1922617442700311060 /* Mastodon+Entity+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Account.swift"; sourceTree = ""; }; 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Tag.swift"; sourceTree = ""; }; 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+History.swift"; sourceTree = ""; }; @@ -1614,6 +1616,7 @@ 5DF1056325F887CB00D6C0D4 /* AVPlayer.swift */, DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, + 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */, 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, @@ -2329,6 +2332,7 @@ DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, + 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, diff --git a/Mastodon/Extension/UIView+Remove.swift b/Mastodon/Extension/UIView+Remove.swift new file mode 100644 index 000000000..473b3c348 --- /dev/null +++ b/Mastodon/Extension/UIView+Remove.swift @@ -0,0 +1,18 @@ +// +// UIView+Remove.swift +// Mastodon +// +// Created by xiaojian sun on 2021/4/16. +// + +import Foundation +import UIKit + +extension UIView { + func removeFromStackView() { + if let stackView = self.superview as? UIStackView { + stackView.removeArrangedSubview(self) + } + self.removeFromSuperview() + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index ddc997a5d..3fb862590 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -35,7 +35,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.tableFooterView = UIView() - tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = UITableView.automaticDimension return tableView }() diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index 774620f8a..c65b5096b 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -74,9 +74,7 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { newSnapshot.appendItems([.bottomLoader], toSection: .main) } guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { - diffableDataSource.apply(newSnapshot, animatingDifferences: false) { - tableView.reloadData() - } + diffableDataSource.apply(newSnapshot, animatingDifferences: false) self.isFetchingLatestNotification.value = false return } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index dc3f49bb0..5d1a14be3 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -11,7 +11,6 @@ import UIKit final class NotificationStatusTableViewCell: UITableViewCell { static let actionImageBorderWidth: CGFloat = 2 - static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? @@ -42,6 +41,11 @@ final class NotificationStatusTableViewCell: UITableViewCell { return view }() + let avatarContainer: UIView = { + let view = UIView() + return view + }() + let actionLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.secondary.color @@ -103,53 +107,99 @@ final class NotificationStatusTableViewCell: UITableViewCell { extension NotificationStatusTableViewCell { func configure() { - - let container = UIView() - container.backgroundColor = .clear - contentView.addSubview(container) - container.constrain([ - container.topAnchor.constraint(equalTo: contentView.topAnchor), - container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.alignment = .top + containerStackView.spacing = 4 + containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) - - container.addSubview(avatatImageView) - avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) - avatatImageView.pin(top: 12, left: 0, bottom: nil, right: nil) - - container.addSubview(actionImageBackground) - actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) - actionImageBackground.pin(top: 33, left: 21, bottom: nil, right: nil) - - actionImageBackground.addSubview(actionImageView) - actionImageView.constrainToCenter() - container.addSubview(nameLabel) - nameLabel.constrain([ - nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), - nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) + containerStackView.addArrangedSubview(avatarContainer) + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), + avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1) + ]) + + avatarContainer.addSubview(avatatImageView) + avatatImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor) + ]) + + avatarContainer.addSubview(actionImageBackground) + actionImageBackground.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor) + ]) + + avatarContainer.addSubview(actionImageView) + actionImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor), + actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) + ]) + + + let actionStackView = UIStackView() + actionStackView.axis = .horizontal + actionStackView.distribution = .fillProportionally + actionStackView.spacing = 4 + actionStackView.translatesAutoresizingMaskIntoConstraints = false + + nameLabel.translatesAutoresizingMaskIntoConstraints = false + actionStackView.addArrangedSubview(nameLabel) + actionLabel.translatesAutoresizingMaskIntoConstraints = false + actionStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + + let statusStackView = UIStackView() + statusStackView.axis = .vertical + + statusStackView.distribution = .fill + statusStackView.spacing = 4 + statusStackView.translatesAutoresizingMaskIntoConstraints = false + statusView.translatesAutoresizingMaskIntoConstraints = false + statusStackView.addArrangedSubview(actionStackView) + + statusBorder.translatesAutoresizingMaskIntoConstraints = false + statusView.translatesAutoresizingMaskIntoConstraints = false + statusBorder.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: statusBorder.topAnchor, constant: 12), + statusView.leadingAnchor.constraint(equalTo: statusBorder.leadingAnchor, constant: 12), + statusBorder.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 12), + statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12), ]) - container.addSubview(actionLabel) - actionLabel.constrain([ - actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), - actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4) - ]) + + statusStackView.addArrangedSubview(statusBorder) + + containerStackView.addArrangedSubview(statusStackView) statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color statusView.isUserInteractionEnabled = false // remove item don't display - statusView.actionToolbarContainer.isHidden = true - statusView.avatarView.isHidden = true - statusView.usernameLabel.isHidden = true - - container.addSubview(statusBorder) - statusBorder.pin(top: 40, left: 63, bottom: 14, right: 14) - - container.addSubview(statusView) - statusView.pin(top: NotificationStatusTableViewCell.statusPadding.top, left: NotificationStatusTableViewCell.statusPadding.left, bottom: NotificationStatusTableViewCell.statusPadding.bottom, right: NotificationStatusTableViewCell.statusPadding.right) + statusView.actionToolbarContainer.removeFromStackView() + // it affect stackView's height + statusView.avatarView.removeFromStackView() + statusView.usernameLabel.removeFromStackView() + statusView.nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + statusView.activeTextLabel.setContentCompressionResistancePriority(.required, for: .vertical) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index cda4d75d7..1b7560a78 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -50,6 +50,11 @@ final class NotificationTableViewCell: UITableViewCell { return view }() + let avatarContainer: UIView = { + let view = UIView() + return view + }() + let actionLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.secondary.color @@ -86,40 +91,67 @@ final class NotificationTableViewCell: UITableViewCell { extension NotificationTableViewCell { func configure() { - let container = UIView() - container.backgroundColor = .clear - contentView.addSubview(container) - container.constrain([ - container.topAnchor.constraint(equalTo: contentView.topAnchor), - container.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), - container.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor), - container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.alignment = .center + containerStackView.spacing = 4 + containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) - - container.addSubview(avatatImageView) - avatatImageView.pin(toSize: CGSize(width: 35, height: 35)) - avatatImageView.pin(top: 12, left: 0, bottom: nil, right: nil) - - container.addSubview(actionImageBackground) - actionImageBackground.pin(toSize: CGSize(width: 24 + NotificationTableViewCell.actionImageBorderWidth, height: 24 + NotificationTableViewCell.actionImageBorderWidth)) - actionImageBackground.pin(top: 33, left: 33, bottom: nil, right: nil) - - actionImageBackground.addSubview(actionImageView) - actionImageView.constrainToCenter() - container.addSubview(nameLabel) - nameLabel.constrain([ - nameLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 24), - container.bottomAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 24), - nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 61) + containerStackView.addArrangedSubview(avatarContainer) + avatarContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), + avatarContainer.widthAnchor.constraint(equalToConstant: 47).priority(.required - 1) ]) + + avatarContainer.addSubview(avatatImageView) + avatatImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + avatatImageView.heightAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.widthAnchor.constraint(equalToConstant: 35).priority(.required - 1), + avatatImageView.topAnchor.constraint(equalTo: avatarContainer.topAnchor), + avatatImageView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor) + ]) + + avatarContainer.addSubview(actionImageBackground) + actionImageBackground.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageBackground.heightAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.widthAnchor.constraint(equalToConstant: 24 + NotificationTableViewCell.actionImageBorderWidth).priority(.required - 1), + actionImageBackground.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor), + actionImageBackground.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor) + ]) + + avatarContainer.addSubview(actionImageView) + actionImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor), + actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) + ]) + + + let actionStackView = UIStackView() + actionStackView.axis = .horizontal + actionStackView.distribution = .fillProportionally + actionStackView.spacing = 4 + actionStackView.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(actionLabel) - actionLabel.constrain([ - actionLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 4), - actionLabel.centerYAnchor.constraint(equalTo: nameLabel.centerYAnchor), - container.trailingAnchor.constraint(greaterThanOrEqualTo: actionLabel.trailingAnchor, constant: 4) - ]) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(nameLabel) + actionLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) + containerStackView.addArrangedSubview(actionStackView) + } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From 780025a3ceee83cb5cbccf1d596a942982ae9100 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 16 Apr 2021 22:58:36 +0800 Subject: [PATCH 262/400] chore: use systemBackground color --- Mastodon/Generated/Assets.swift | 1 - .../Background/pure.colorset/Contents.json | 38 ------------------- .../NotificationViewController.swift | 2 +- .../NotificationStatusTableViewCell.swift | 13 ++++--- .../NotificationTableViewCell.swift | 18 +++------ .../Scene/Search/SearchViewController.swift | 2 +- 6 files changed, 15 insertions(+), 59 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 0e0869f00..2ffc882bb 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -44,7 +44,6 @@ internal enum Asset { internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") - internal static let pure = ColorAsset(name: "Colors/Background/pure") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json deleted file mode 100644 index 82edd034b..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/pure.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "30", - "green" : "28", - "red" : "28" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 3fb862590..7dccb423e 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -45,7 +45,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.pure.color + view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) view.addSubview(tableView) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 5d1a14be3..cdde9dbe0 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -26,7 +26,7 @@ final class NotificationStatusTableViewCell: UITableViewCell { let actionImageView: UIImageView = { let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Background.pure.color + imageView.tintColor = Asset.Colors.Background.systemBackground.color return imageView }() @@ -36,8 +36,8 @@ final class NotificationStatusTableViewCell: UITableViewCell { view.layer.cornerCurve = .continuous view.clipsToBounds = true view.layer.borderWidth = NotificationStatusTableViewCell.actionImageBorderWidth - view.layer.borderColor = Asset.Colors.Background.pure.color.cgColor - view.tintColor = Asset.Colors.Background.pure.color + view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + view.tintColor = Asset.Colors.Background.systemBackground.color return view }() @@ -157,7 +157,7 @@ extension NotificationStatusTableViewCell { let actionStackView = UIStackView() actionStackView.axis = .horizontal - actionStackView.distribution = .fillProportionally + actionStackView.distribution = .fill actionStackView.spacing = 4 actionStackView.translatesAutoresizingMaskIntoConstraints = false @@ -166,7 +166,8 @@ extension NotificationStatusTableViewCell { actionLabel.translatesAutoresizingMaskIntoConstraints = false actionStackView.addArrangedSubview(actionLabel) nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - + nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) let statusStackView = UIStackView() statusStackView.axis = .vertical @@ -205,6 +206,6 @@ extension NotificationStatusTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor - actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor } } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 1b7560a78..60b43ac35 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -35,7 +35,7 @@ final class NotificationTableViewCell: UITableViewCell { let actionImageView: UIImageView = { let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Background.pure.color + imageView.tintColor = Asset.Colors.Background.systemBackground.color return imageView }() @@ -45,8 +45,8 @@ final class NotificationTableViewCell: UITableViewCell { view.layer.cornerCurve = .continuous view.clipsToBounds = true view.layer.borderWidth = NotificationTableViewCell.actionImageBorderWidth - view.layer.borderColor = Asset.Colors.Background.pure.color.cgColor - view.tintColor = Asset.Colors.Background.pure.color + view.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + view.tintColor = Asset.Colors.Background.systemBackground.color return view }() @@ -137,25 +137,19 @@ extension NotificationTableViewCell { actionImageView.centerXAnchor.constraint(equalTo: actionImageBackground.centerXAnchor), actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) ]) - - - let actionStackView = UIStackView() - actionStackView.axis = .horizontal - actionStackView.distribution = .fillProportionally - actionStackView.spacing = 4 - actionStackView.translatesAutoresizingMaskIntoConstraints = false nameLabel.translatesAutoresizingMaskIntoConstraints = false containerStackView.addArrangedSubview(nameLabel) actionLabel.translatesAutoresizingMaskIntoConstraints = false containerStackView.addArrangedSubview(actionLabel) nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - containerStackView.addArrangedSubview(actionStackView) + nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - actionImageBackground.layer.borderColor = Asset.Colors.Background.pure.color.cgColor + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 4fee226bf..710808b52 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -82,7 +82,7 @@ final class SearchViewController: UIViewController, NeedsDependency { // searching let searchingTableView: UITableView = { let tableView = UITableView() - tableView.backgroundColor = Asset.Colors.Background.pure.color + tableView.backgroundColor = Asset.Colors.Background.systemBackground.color tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) From 9be8b95aeaecc4fb0b68b59a03ab2fc86763d07f Mon Sep 17 00:00:00 2001 From: ihugo Date: Sat, 17 Apr 2021 14:01:57 +0800 Subject: [PATCH 263/400] fix: use right privacyURL - remove some redundancy code --- CoreDataStack/Entity/SubscriptionAlerts.swift | 1 - Mastodon/Extension/UIButton.swift | 20 ++++--------------- .../Settings/SettingsViewController.swift | 7 +++---- .../Scene/Settings/SettingsViewModel.swift | 8 ++++++++ .../MastodonSDK/API/Mastodon+API+Push.swift | 6 +++--- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index c240a02a5..d1169104a 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -9,7 +9,6 @@ import Foundation import CoreData -@objc(SubscriptionAlerts) public final class SubscriptionAlerts: NSManagedObject { @NSManaged public var follow: NSNumber? @NSManaged public var favourite: NSNumber? diff --git a/Mastodon/Extension/UIButton.swift b/Mastodon/Extension/UIButton.swift index d4334baad..31043157a 100644 --- a/Mastodon/Extension/UIButton.swift +++ b/Mastodon/Extension/UIButton.swift @@ -44,22 +44,10 @@ extension UIButton { } extension UIButton { - // https://stackoverflow.com/questions/14523348/how-to-change-the-background-color-of-a-uibutton-while-its-highlighted - private func image(withColor color: UIColor) -> UIImage? { - let rect = CGRect(x: 0.0, y: 0.0, width: 1.0, height: 1.0) - UIGraphicsBeginImageContext(rect.size) - let context = UIGraphicsGetCurrentContext() - - context?.setFillColor(color.cgColor) - context?.fill(rect) - - let image = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() - - return image - } - func setBackgroundColor(_ color: UIColor, for state: UIControl.State) { - self.setBackgroundImage(image(withColor: color), for: state) + self.setBackgroundImage( + UIImage.placeholder(color: color), + for: state + ) } } diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4bdc115b2..a9b1f0b82 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -311,8 +311,9 @@ extension SettingsViewController: UITableViewDelegate { switch item { case .boringZone: + guard let url = viewModel.privacyURL else { break } coordinator.present( - scene: .safari(url: URL(string: "https://mastodon.online/terms")!), + scene: .safari(url: url), from: self, transition: .safariPresent(animated: true, completion: nil) ) @@ -345,11 +346,9 @@ extension SettingsViewController { func updateTrigger(by who: String) { guard let setting = self.viewModel.setting.value else { return } - context.managedObjectContext.performChanges { + _ = context.managedObjectContext.performChanges { setting.update(triggerBy: who) } - .sink { (_) in - }.store(in: &disposeBag) } func updateAlert(title: String?, isOn: Bool) { diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 9ffec7f8d..b61334f4a 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -70,6 +70,14 @@ class SettingsViewModel: NSObject, NeedsDependency { noOne: noOneSwitchItems] }() + lazy var privacyURL: URL? = { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + + return Mastodon.API.privacyURL(domain: box.domain) + }() + struct Input { } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index 062b45ac2..df9168499 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -28,7 +28,7 @@ extension Mastodon.API.Subscriptions { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - authorization: User token. Could be nil if status is public - /// - Returns: `AnyPublisher` contains `Poll` nested in the response + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response public static func subscription( session: URLSession, domain: String, @@ -61,7 +61,7 @@ extension Mastodon.API.Subscriptions { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - authorization: User token. Could be nil if status is public - /// - Returns: `AnyPublisher` contains `Poll` nested in the response + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response public static func createSubscription( session: URLSession, domain: String, @@ -95,7 +95,7 @@ extension Mastodon.API.Subscriptions { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - authorization: User token. Could be nil if status is public - /// - Returns: `AnyPublisher` contains `Poll` nested in the response + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response public static func updateSubscription( session: URLSession, domain: String, From e42af11bf7afe784f9adfc7d1e7f7a51cd4f9a0b Mon Sep 17 00:00:00 2001 From: ihugo Date: Sat, 17 Apr 2021 14:15:55 +0800 Subject: [PATCH 264/400] chore: generate strings and assets --- Mastodon/Generated/Assets.swift | 6 ++ Mastodon/Generated/Strings.swift | 64 +++++++++++++++++++ ...meTimelineViewController+DebugAction.swift | 7 ++ 3 files changed, 77 insertions(+) diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index cd655e077..27c8391b4 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -80,6 +80,7 @@ internal enum Asset { internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") } + internal static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey") internal static let brandBlue = ColorAsset(name: "Colors/brand.blue") internal static let danger = ColorAsset(name: "Colors/danger") internal static let disabled = ColorAsset(name: "Colors/disabled") @@ -120,6 +121,11 @@ internal enum Asset { internal static let mastodonLogoLarge = ImageAsset(name: "Scene/Welcome/mastodon.logo.large") } } + internal enum Settings { + internal static let appearanceAutomatic = ImageAsset(name: "Settings/appearance.automatic") + internal static let appearanceDark = ImageAsset(name: "Settings/appearance.dark") + internal static let appearanceLight = ImageAsset(name: "Settings/appearance.light") + } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6eed41a29..ddbd87617 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -35,6 +35,14 @@ internal enum L10n { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") } + internal enum SignOut { + /// Sign Out + internal static let confirm = L10n.tr("Localizable", "Common.Alerts.SignOut.Confirm") + /// Are you sure you want to sign out? + internal static let message = L10n.tr("Localizable", "Common.Alerts.SignOut.Message") + /// Sign out + internal static let title = L10n.tr("Localizable", "Common.Alerts.SignOut.Title") + } internal enum SignUpFailure { /// Sign Up Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.SignUpFailure.Title") @@ -591,6 +599,62 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Scene.ServerRules.Button.Confirm") } } + internal enum Settings { + /// Settings + internal static let title = L10n.tr("Localizable", "Scene.Settings.Title") + internal enum Section { + internal enum Appearance { + /// Automatic + internal static let automatic = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Automatic") + /// Always Dark + internal static let dark = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Dark") + /// Always Light + internal static let light = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Light") + /// Appearance + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Appearance.Title") + } + internal enum Boringzone { + /// Privacy Policy + internal static let privacy = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Privacy") + /// Terms of Service + internal static let terms = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Terms") + /// The Boring zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Boringzone.Title") + } + internal enum Notifications { + /// Reblogs my post + internal static let boosts = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Boosts") + /// Favorites my post + internal static let favorites = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Favorites") + /// Follows me + internal static let follows = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Follows") + /// Mentions me + internal static let mentions = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Mentions") + /// Notifications + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Title") + internal enum Trigger { + /// anyone + internal static let anyone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Anyone") + /// anyone I follow + internal static let follow = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follow") + /// a follower + internal static let follower = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Follower") + /// no one + internal static let noone = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Noone") + /// Notify me when + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Notifications.Trigger.Title") + } + } + internal enum Spicyzone { + /// Clear Media Cache + internal static let clear = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Clear") + /// Sign Out + internal static let signout = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Signout") + /// The spicy zone + internal static let title = L10n.tr("Localizable", "Scene.Settings.Section.Spicyzone.Title") + } + } + } internal enum Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 401e4fc14..8bbf9436e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -37,6 +37,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showThreadAction(action) }, + UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showSettings(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -323,5 +327,8 @@ extension HomeTimelineViewController { coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) } + @objc private func showSettings(_ sender: UIAction) { + coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + } } #endif From 8c7149af8991685549f5bac3137d9cf97b8a601f Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 18 Apr 2021 02:02:08 +0800 Subject: [PATCH 265/400] fix: server-side data is inconsistent with local --- .../CoreData.xcdatamodel/contents | 11 +- CoreDataStack/Entity/Setting.swift | 14 ++- CoreDataStack/Entity/Subscription.swift | 5 +- CoreDataStack/Entity/SubscriptionAlerts.swift | 2 +- .../Settings/SettingsViewController.swift | 66 ++++++----- .../Scene/Settings/SettingsViewModel.swift | 93 ++++++++++------ .../APIService/APIService+Subscriptions.swift | 105 +++++++++++++++--- .../APIService+CoreData+Subscriptions.swift | 41 ++----- 8 files changed, 212 insertions(+), 125 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 25579daa0..bf87198ec 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -155,13 +155,14 @@ - + + @@ -202,7 +203,7 @@ - + @@ -212,7 +213,7 @@ - + @@ -244,10 +245,10 @@ + - - + \ No newline at end of file diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index a4907f0ab..671f9bab3 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -8,11 +8,11 @@ import CoreData import Foundation -@objc(Setting) public final class Setting: NSManagedObject { @NSManaged public var appearance: String? @NSManaged public var triggerBy: String? @NSManaged public var domain: String? + @NSManaged public var userID: String? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date @@ -40,6 +40,7 @@ public extension Setting { setting.appearance = property.appearance setting.triggerBy = property.triggerBy setting.domain = property.domain + setting.userID = property.userID return setting } @@ -61,11 +62,13 @@ public extension Setting { public let appearance: String public let triggerBy: String public let domain: String + public let userID: String - public init(appearance: String, triggerBy: String, domain: String) { + public init(appearance: String, triggerBy: String, domain: String, userID: String) { self.appearance = appearance self.triggerBy = triggerBy self.domain = domain + self.userID = userID } } } @@ -77,8 +80,11 @@ extension Setting: Managed { } extension Setting { - public static func predicate(domain: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Setting.domain), domain) + public static func predicate(domain: String, userID: String) -> NSPredicate { + return NSPredicate(format: "%K == %@ AND %K == %@", + #keyPath(Setting.domain), domain, + #keyPath(Setting.userID), userID + ) } } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 7d7a74570..8ced945d9 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -9,7 +9,6 @@ import Foundation import CoreData -@objc(Subscription) public final class Subscription: NSManagedObject { @NSManaged public var id: String @NSManaged public var endpoint: String @@ -95,8 +94,8 @@ extension Subscription: Managed { extension Subscription { - public static func predicate(id: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Subscription.id), id) + public static func predicate(type: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) } } diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index d1169104a..f5abf4955 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -20,7 +20,7 @@ public final class SubscriptionAlerts: NSManagedObject { @NSManaged public private(set) var updatedAt: Date // MARK: - relationships - @NSManaged public var pushSubscription: Subscription? + @NSManaged public var subscription: Subscription? } public extension SubscriptionAlerts { diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index a9b1f0b82..4615f92ab 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -46,7 +46,7 @@ class SettingsViewController: UIViewController, NeedsDependency { UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in self?.updateTrigger(by: noOne) }, - ].reversed() + ] ) return menu } @@ -344,10 +344,15 @@ extension SettingsViewController: UITableViewDelegate { // Update setting into core data extension SettingsViewController { func updateTrigger(by who: String) { + guard self.viewModel.triggerBy != who else { return } guard let setting = self.viewModel.setting.value else { return } - _ = context.managedObjectContext.performChanges { - setting.update(triggerBy: who) + setting.update(triggerBy: who) + // trigger to call `subscription` API with POST method + // confirm the local data is correct even if request failed + // The asynchronous execution is to solve the problem of dropped frames for animations. + DispatchQueue.main.async { [weak self] in + self?.viewModel.setting.value = setting } } @@ -356,34 +361,35 @@ extension SettingsViewController { guard let settings = self.viewModel.setting.value else { return } guard let triggerBy = settings.triggerBy else { return } - guard let alerts = settings.subscription?.first(where: { (s) -> Bool in + if let alerts = settings.subscription?.first(where: { (s) -> Bool in return s.type == settings.triggerBy - })?.alert else { - return + })?.alert { + var alertValues = [Bool?]() + alertValues.append(alerts.favourite?.boolValue) + alertValues.append(alerts.follow?.boolValue) + alertValues.append(alerts.reblog?.boolValue) + alertValues.append(alerts.mention?.boolValue) + + // need to update `alerts` to make update API with correct parameter + switch title { + case L10n.Scene.Settings.Section.Notifications.favorites: + alertValues[0] = isOn + alerts.favourite = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.follows: + alertValues[1] = isOn + alerts.follow = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.boosts: + alertValues[2] = isOn + alerts.reblog = NSNumber(booleanLiteral: isOn) + case L10n.Scene.Settings.Section.Notifications.mentions: + alertValues[3] = isOn + alerts.mention = NSNumber(booleanLiteral: isOn) + default: break + } + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { + self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) } - var alertValues = [Bool?]() - alertValues.append(alerts.favourite?.boolValue) - alertValues.append(alerts.follow?.boolValue) - alertValues.append(alerts.reblog?.boolValue) - alertValues.append(alerts.mention?.boolValue) - - // need to update `alerts` to make update API with correct parameter - switch title { - case L10n.Scene.Settings.Section.Notifications.favorites: - alertValues[0] = isOn - alerts.favourite = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.follows: - alertValues[1] = isOn - alerts.follow = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.boosts: - alertValues[2] = isOn - alerts.reblog = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.mentions: - alertValues[3] = isOn - alerts.mention = NSNumber(booleanLiteral: isOn) - default: break - } - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) } } @@ -435,7 +441,7 @@ extension SettingsViewController { guard let setting: Setting? = { let domain = box.domain let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain) + request.predicate = Setting.predicate(domain: domain, userID: box.userID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index b61334f4a..470617aeb 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -29,7 +29,7 @@ class SettingsViewModel: NSObject, NeedsDependency { if let box = self.context.authenticationService.activeMastodonAuthenticationBox.value { let domain = box.domain - fetchRequest.predicate = Setting.predicate(domain: domain) + fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) } fetchRequest.fetchLimit = 1 @@ -78,6 +78,9 @@ class SettingsViewModel: NSObject, NeedsDependency { return Mastodon.API.privacyURL(domain: box.domain) }() + /// to store who trigger the notification. + var triggerBy: String? + struct Input { } @@ -121,12 +124,14 @@ class SettingsViewModel: NSObject, NeedsDependency { follow: values[1], reblog: values[2], mention: values[3], - poll: nil) + poll: nil + ) self.context.apiService.changeSubscription( domain: domain, mastodonAuthenticationBox: activeMastodonAuthenticationBox, query: query, - triggerBy: triggerBy + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID ) .sink { (_) in } receiveValue: { (_) in @@ -164,7 +169,8 @@ class SettingsViewModel: NSObject, NeedsDependency { domain: domain, mastodonAuthenticationBox: activeMastodonAuthenticationBox, query: query, - triggerBy: triggerBy + triggerBy: triggerBy, + userID: activeMastodonAuthenticationBox.userID ) .sink { (_) in } receiveValue: { (_) in @@ -178,13 +184,6 @@ class SettingsViewModel: NSObject, NeedsDependency { // request subsription data for updating or initialization requestSubscription() - - do { - try fetchResultsController.performFetch() - setting.value = fetchResultsController.fetchedObjects?.first - } catch { - assertionFailure(error.localizedDescription) - } return nil } @@ -213,12 +212,12 @@ class SettingsViewModel: NSObject, NeedsDependency { } else if let triggerBy = settings?.triggerBy, let values = self.notificationDefaultValue[triggerBy] { switches = values - self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) } else { // fallback a default value let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone switches = self.notificationDefaultValue[anyone] } + let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, L10n.Scene.Settings.Section.Notifications.follows, L10n.Scene.Settings.Section.Notifications.boosts, @@ -273,31 +272,61 @@ class SettingsViewModel: NSObject, NeedsDependency { } private func requestSubscription() { - // request subscription of notifications - typealias SubscriptionResponse = Mastodon.Response.Content - viewDidLoad.flatMap { [weak self] (_) -> AnyPublisher in - guard let self = self, - let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return Empty().eraseToAnyPublisher() + setting.sink { [weak self] (settings) in + guard let self = self else { return } + guard settings != nil else { return } + guard self.triggerBy != settings?.triggerBy else { return } + self.triggerBy = settings?.triggerBy + + var switches: [Bool?]? + var who: String? + if let alerts = settings?.subscription?.first(where: { (s) -> Bool in + return s.type == settings?.triggerBy + })?.alert { + var items = [Bool?]() + items.append(alerts.favourite?.boolValue) + items.append(alerts.follow?.boolValue) + items.append(alerts.reblog?.boolValue) + items.append(alerts.mention?.boolValue) + switches = items + who = settings?.triggerBy + } else if let triggerBy = settings?.triggerBy, + let values = self.notificationDefaultValue[triggerBy] { + switches = values + who = triggerBy + } else { + // fallback a default value + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + switches = self.notificationDefaultValue[anyone] + who = anyone } - let domain = activeMastodonAuthenticationBox.domain - return self.context.apiService.subscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox) - } - .sink { [weak self] competion in - if case .failure(_) = competion { - // create a subscription when doesn't has one - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - if let values = self?.notificationDefaultValue[anyone] { - self?.createSubscriptionSubject.send((triggerBy: anyone, values: values)) - } + // should create a subscription whenever change trigger + if let values = switches, let triggerBy = who { + self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) } - } receiveValue: { (subscription) in } .store(in: &disposeBag) + + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + let userId = activeMastodonAuthenticationBox.userID + + do { + try fetchResultsController.performFetch() + if nil == fetchResultsController.fetchedObjects?.first { + let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone + setting.value = self.context.apiService.createSettingIfNeed(domain: domain, + userId: userId, + triggerBy: anyone) + } else { + setting.value = fetchResultsController.fetchedObjects?.first + } + } catch { + assertionFailure(error.localizedDescription) + } } deinit { diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index d67284c7c..337ab26d2 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -5,44 +5,71 @@ // Created by ihugo on 2021/4/9. // +import Combine +import CoreData +import CoreDataStack import Foundation import MastodonSDK -import Combine extension APIService { func subscription( domain: String, + userID: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - + let findSettings: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let triggerBy = findSettings?.triggerBy ?? "anyone" + let setting = self.createSettingIfNeed( + domain: domain, + userId: userID, + triggerBy: triggerBy + ) return Mastodon.API.Subscriptions.subscription( session: session, domain: domain, - authorization: authorization) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSubscription( + into: self.backgroundManagedObjectContext, + entity: response.value, + domain: domain, + triggerBy: triggerBy, + setting: setting) + } + .setFailureType(to: Error.self) + .map { _ in return response } + .eraseToAnyPublisher() + }.eraseToAnyPublisher() } func changeSubscription( domain: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, - triggerBy: String + triggerBy: String, + userID: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, @@ -55,7 +82,9 @@ extension APIService { into: self.backgroundManagedObjectContext, entity: response.value, domain: domain, - triggerBy: triggerBy) + triggerBy: triggerBy, + setting: setting + ) } .setFailureType(to: Error.self) .map { _ in return response } @@ -67,10 +96,15 @@ extension APIService { domain: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, - triggerBy: String + triggerBy: String, + userID: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let setting = self.createSettingIfNeed(domain: domain, + userId: userID, + triggerBy: triggerBy) + return Mastodon.API.Subscriptions.updateSubscription( session: session, domain: domain, @@ -83,12 +117,47 @@ extension APIService { into: self.backgroundManagedObjectContext, entity: response.value, domain: domain, - triggerBy: triggerBy) + triggerBy: triggerBy, + setting: setting + ) } .setFailureType(to: Error.self) .map { _ in return response } .eraseToAnyPublisher() }.eraseToAnyPublisher() } + + func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { + // create setting entity if possible + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: domain, userID: userId) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + var setting: Setting! + if let oldSetting = oldSetting { + setting = oldSetting + } else { + let property = Setting.Property( + appearance: "automatic", + triggerBy: triggerBy, + domain: domain, + userID: userId) + (setting, _) = APIService.CoreData.createOrMergeSetting( + into: backgroundManagedObjectContext, + domain: domain, + userID: userId, + property: property + ) + } + return setting + } } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index 8dc189734..f5a4022ea 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -16,11 +16,12 @@ extension APIService.CoreData { static func createOrMergeSetting( into managedObjectContext: NSManagedObjectContext, domain: String, + userID: String, property: Setting.Property ) -> (Subscription: Setting, isCreated: Bool) { let oldSetting: Setting? = { let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: property.domain) + request.predicate = Setting.predicate(domain: property.domain, userID: userID) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { @@ -45,38 +46,12 @@ extension APIService.CoreData { into managedObjectContext: NSManagedObjectContext, entity: Mastodon.Entity.Subscription, domain: String, - triggerBy: String? = nil + triggerBy: String, + setting: Setting ) -> (Subscription: Subscription, isCreated: Bool) { - // create setting entity if possible - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - var setting: Setting! - if let oldSetting = oldSetting { - setting = oldSetting - } else { - let property = Setting.Property( - appearance: "automatic", - triggerBy: "anyone", - domain: domain) - (setting, _) = createOrMergeSetting( - into: managedObjectContext, - domain: domain, - property: property) - } - let oldSubscription: Subscription? = { let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(id: entity.id) + request.predicate = Subscription.predicate(type: triggerBy) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { @@ -91,7 +66,8 @@ extension APIService.CoreData { endpoint: entity.endpoint, id: entity.id, serverKey: entity.serverKey, - type: triggerBy ?? setting.triggerBy ?? "") + type: triggerBy + ) let alertEntity = entity.alerts let alert = SubscriptionAlerts.Property( favourite: alertEntity.favouriteNumber, @@ -105,7 +81,8 @@ extension APIService.CoreData { if nil == oldSubscription.alert { oldSubscription.alert = SubscriptionAlerts.insert( into: managedObjectContext, - property: alert) + property: alert + ) } else { oldSubscription.alert?.updateIfNeed(property: alert) } From 462fafe706090cfbd1a8526540c2e12e59e2d184 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 18 Apr 2021 22:00:19 +0800 Subject: [PATCH 266/400] chore: add navigation to ThreadScene --- .../NotificationViewController.swift | 38 +++++++++---------- .../NotificationViewModel+diffable.swift | 1 + 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 7dccb423e..68e69b4be 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -16,16 +16,16 @@ import UIKit final class NotificationViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - + var disposeBag = Set() private(set) lazy var viewModel = NotificationViewModel(context: context) - + let segmentControl: UISegmentedControl = { let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) control.selectedSegmentIndex = 0 return control }() - + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.rowHeight = UITableView.automaticDimension @@ -38,7 +38,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.estimatedRowHeight = UITableView.automaticDimension return tableView }() - + let refreshControl = UIRefreshControl() } @@ -55,10 +55,10 @@ extension NotificationViewController { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - + tableView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(NotificationViewController.refreshControlValueChanged(_:)), for: .valueChanged) - + tableView.delegate = self viewModel.tableView = tableView viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self @@ -78,10 +78,10 @@ extension NotificationViewController { } .store(in: &disposeBag) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } @@ -96,7 +96,7 @@ extension NotificationViewController { } } } - + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -117,11 +117,11 @@ extension NotificationViewController { if sender.selectedSegmentIndex == 0 { viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) } else { - viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain,userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) + viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) } - viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment.init(rawValue: sender.selectedSegmentIndex)! + viewModel.selectedIndex.value = NotificationViewModel.NotificationSegment(rawValue: sender.selectedSegmentIndex)! } - + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { guard viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) else { sender.endRefreshing() @@ -134,24 +134,24 @@ extension NotificationViewController { extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { case .notification(let objectID): let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification - if notification.status != nil { - // TODO: goto status detail vc + if let status = notification.status { + let viewModel = ThreadViewModel(context: context, optionalStatus: status) + coordinator.present(scene: .thread(viewModel: viewModel), from: self, transition: .show) } else { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) - DispatchQueue.main.async { - self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) - } + coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) } default: break } } - + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } @@ -181,7 +181,7 @@ extension NotificationViewController: NotificationTableViewCellDelegate { self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) } } - + func parent() -> UIViewController { self } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index c65b5096b..5bd2d92dd 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -76,6 +76,7 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { guard let difference = self.calculateReloadSnapshotDifference(navigationBar: navigationBar, tableView: tableView, oldSnapshot: oldSnapshot, newSnapshot: newSnapshot) else { diffableDataSource.apply(newSnapshot, animatingDifferences: false) self.isFetchingLatestNotification.value = false + tableView.reloadData() return } From 5ae2c446422fc788f8b59a50b56693cb0762e42d Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 10:10:46 +0800 Subject: [PATCH 267/400] chore: remove useless code --- .../TableViewCell/NotificationStatusTableViewCell.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index cdde9dbe0..aae4c9e2f 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -196,11 +196,9 @@ extension NotificationStatusTableViewCell { statusView.isUserInteractionEnabled = false // remove item don't display statusView.actionToolbarContainer.removeFromStackView() - // it affect stackView's height + // it affect stackView's height,need remove statusView.avatarView.removeFromStackView() statusView.usernameLabel.removeFromStackView() - statusView.nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - statusView.activeTextLabel.setContentCompressionResistancePriority(.required, for: .vertical) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From f6be51dd0fff8ba2b09b9f53da37235529e415b3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 11:41:50 +0800 Subject: [PATCH 268/400] chore: remove UIView+Contraint --- Mastodon.xcodeproj/project.pbxproj | 4 - Mastodon/Extension/UIView+Constraint.swift | 262 ------------------ .../NotificationViewController.swift | 3 +- ...hRecommendAccountsCollectionViewCell.swift | 77 +++-- ...earchRecommendTagsCollectionViewCell.swift | 59 +++- .../SearchViewController+Recommend.swift | 6 +- .../SearchViewController+Searching.swift | 31 ++- .../Scene/Search/SearchViewController.swift | 14 +- Mastodon/Scene/Search/SearchViewModel.swift | 4 +- .../SearchingTableViewCell.swift | 40 ++- .../SearchRecommendCollectionHeader.swift | 33 ++- 11 files changed, 191 insertions(+), 342 deletions(-) delete mode 100644 Mastodon/Extension/UIView+Constraint.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 427ffa0db..659d0d734 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -132,7 +132,6 @@ 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; - 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; @@ -531,7 +530,6 @@ 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectContextObjectsDidChange.swift; sourceTree = ""; }; 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Searching.swift"; sourceTree = ""; }; 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchingTableViewCell.swift; sourceTree = ""; }; - 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Constraint.swift"; sourceTree = ""; }; 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.debug.xcconfig"; sourceTree = ""; }; 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1646,7 +1644,6 @@ DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */, 2D32EAB925CB9B0500C9ED86 /* UIView.swift */, 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */, - 2DFF41882614A4DC00F776A4 /* UIView+Constraint.swift */, DB8AF55C25C138B7002E6C99 /* UIViewController.swift */, 2D24E1222626ED9D00A59D4F /* UIView+Gesture.swift */, 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, @@ -2476,7 +2473,6 @@ 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, - 2DFF41892614A4DC00F776A4 /* UIView+Constraint.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */, DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, diff --git a/Mastodon/Extension/UIView+Constraint.swift b/Mastodon/Extension/UIView+Constraint.swift deleted file mode 100644 index ded8846d4..000000000 --- a/Mastodon/Extension/UIView+Constraint.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// UIView+Constraint.swift -// Mastodon -// -// Created by sxiaojian on 2021/3/31. -// - -import UIKit - -enum Dimension { - case width - case height - - var layoutAttribute: NSLayoutConstraint.Attribute { - switch self { - case .width: - return .width - case .height: - return .height - } - } - -} - -extension UIView { - - func constrain(toSuperviewEdges: UIEdgeInsets?) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return} - translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - NSLayoutConstraint(item: self, - attribute: .leading, - relatedBy: .equal, - toItem: view, - attribute: .leading, - multiplier: 1.0, - constant: toSuperviewEdges?.left ?? 0.0), - NSLayoutConstraint(item: self, - attribute: .top, - relatedBy: .equal, - toItem: view, - attribute: .top, - multiplier: 1.0, - constant: toSuperviewEdges?.top ?? 0.0), - NSLayoutConstraint(item: view, - attribute: .trailing, - relatedBy: .equal, - toItem: self, - attribute: .trailing, - multiplier: 1.0, - constant: toSuperviewEdges?.right ?? 0.0), - NSLayoutConstraint(item: view, - attribute: .bottom, - relatedBy: .equal, - toItem: self, - attribute: .bottom, - multiplier: 1.0, - constant: toSuperviewEdges?.bottom ?? 0.0) - ]) - } - - func constrain(_ constraints: [NSLayoutConstraint?]) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate(constraints.compactMap { $0 }) - } - - func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView, constant: CGFloat?) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: constant ?? 0.0) - } - - func constraint(_ attribute: NSLayoutConstraint.Attribute, toView: UIView) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil} - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: toView, attribute: attribute, multiplier: 1.0, constant: 0.0) - } - - func constraint(_ dimension: Dimension, constant: CGFloat) -> NSLayoutConstraint? { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return nil } - translatesAutoresizingMaskIntoConstraints = false - return NSLayoutConstraint(item: self, - attribute: dimension.layoutAttribute, - relatedBy: .equal, - toItem: nil, - attribute: .notAnAttribute, - multiplier: 1.0, - constant: constant) - } - - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat, topLayoutGuide: UILayoutSupport) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - NSLayoutConstraint(item: self, attribute: .top, relatedBy: .equal, toItem: topLayoutGuide, attribute: .bottom, multiplier: 1.0, constant: topPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainTopCorners(sidePadding: CGFloat, topPadding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - constraint(.top, toView: view, constant: topPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainTopCorners(height: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view), - constraint(.top, toView: view), - constraint(.trailing, toView: view), - constraint(.height, constant: height) - ]) - } - - func constrainBottomCorners(sidePadding: CGFloat, bottomPadding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view, constant: sidePadding), - constraint(.bottom, toView: view, constant: -bottomPadding), - constraint(.trailing, toView: view, constant: -sidePadding) - ]) - } - - func constrainBottomCorners(height: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.leading, toView: view), - constraint(.bottom, toView: view), - constraint(.trailing, toView: view), - constraint(.height, constant: height) - ]) - } - - func constrainLeadingCorners() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.top, toView: view), - constraint(.leading, toView: view), - constraint(.bottom, toView: view) - ]) - } - - func constrainTrailingCorners() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.top, toView: view), - constraint(.trailing, toView: view), - constraint(.bottom, toView: view) - ]) - } - - func constrainToCenter() { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - constraint(.centerX, toView: view), - constraint(.centerY, toView: view) - ]) - } - - func pin(toSize: CGSize) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - widthAnchor.constraint(equalToConstant: toSize.width).priority(.required - 1), - heightAnchor.constraint(equalToConstant: toSize.height).priority(.required - 1) - ]) - } - - func pin(top: CGFloat?,left: CGFloat?,bottom: CGFloat?, right: CGFloat?) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - var constraints = [NSLayoutConstraint]() - if let topConstant = top { - constraints.append(topAnchor.constraint(equalTo: view.topAnchor, constant: topConstant)) - } - if let leftConstant = left { - constraints.append(leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: leftConstant)) - } - if let bottomConstant = bottom { - constraints.append(view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant)) - } - if let rightConstant = right { - constraints.append(view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: rightConstant)) - } - constrain(constraints) - - } - func pinTopLeft(padding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: padding), - topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) - } - - func pinTopLeft(top: CGFloat, left: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: left), - topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) - } - - func pinTopRight(padding: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: padding), - topAnchor.constraint(equalTo: view.topAnchor, constant: padding)]) - } - - func pinTopRight(top: CGFloat, right: CGFloat) { - guard let view = superview else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: right), - topAnchor.constraint(equalTo: view.topAnchor, constant: top)]) - } - - func pinTopLeft(toView: UIView, topPadding: CGFloat) { - guard superview != nil else { assert(false, "Superview cannot be nil when adding contraints"); return } - translatesAutoresizingMaskIntoConstraints = false - constrain([ - leadingAnchor.constraint(equalTo: toView.leadingAnchor), - topAnchor.constraint(equalTo: toView.bottomAnchor, constant: topPadding)]) - } - - /// Cross-fades between two views by animating their alpha then setting one or the other hidden. - /// - parameters: - /// - lhs: left view - /// - rhs: right view - /// - toRight: fade to the right view if true, fade to the left view if false - /// - duration: animation duration - /// - static func crossfade(_ lhs: UIView, _ rhs: UIView, toRight: Bool, duration: TimeInterval) { - lhs.alpha = toRight ? 1.0 : 0.0 - rhs.alpha = toRight ? 0.0 : 1.0 - lhs.isHidden = false - rhs.isHidden = false - - UIView.animate(withDuration: duration, animations: { - lhs.alpha = toRight ? 0.0 : 1.0 - rhs.alpha = toRight ? 1.0 : 0.0 - }, completion: { _ in - lhs.isHidden = toRight - rhs.isHidden = !toRight - }) - } -} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 68e69b4be..90d72afa5 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -48,8 +48,9 @@ extension NotificationViewController { view.backgroundColor = Asset.Colors.Background.systemBackground.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) + tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) - tableView.constrain([ + NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index f45124671..fdb1af562 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -104,43 +104,60 @@ extension SearchRecommendAccountsCollectionViewCell { layer.cornerCurve = .continuous clipsToBounds = false applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) + + headerImageView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(headerImageView) - headerImageView.pin(top: 16, left: 0, bottom: 0, right: 0) + NSLayoutConstraint.activate([ + headerImageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), + headerImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + headerImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + headerImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.distribution = .fill + containerStackView.alignment = .center + containerStackView.spacing = 6 + containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + + avatarImageView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(avatarImageView) - avatarImageView.pin(toSize: CGSize(width: 88, height: 88)) - avatarImageView.constrain([ - avatarImageView.constraint(.top, toView: contentView), - avatarImageView.constraint(.centerX, toView: contentView) + NSLayoutConstraint.activate([ + avatarImageView.widthAnchor.constraint(equalToConstant: 88), + avatarImageView.heightAnchor.constraint(equalToConstant: 88) ]) + containerStackView.addArrangedSubview(avatarImageView) + containerStackView.setCustomSpacing(20, after: avatarImageView) + displayNameLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(displayNameLabel) + containerStackView.setCustomSpacing(0, after: displayNameLabel) - contentView.addSubview(displayNameLabel) - displayNameLabel.constrain([ - displayNameLabel.constraint(.top, toView: contentView, constant: 108), - displayNameLabel.constraint(.leading, toView: contentView), - displayNameLabel.constraint(.trailing, toView: contentView), - displayNameLabel.constraint(.centerX, toView: contentView) - ]) + acctLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(acctLabel) + containerStackView.setCustomSpacing(7, after: acctLabel) - contentView.addSubview(acctLabel) - acctLabel.constrain([ - acctLabel.constraint(.top, toView: contentView, constant: 132), - acctLabel.constraint(.leading, toView: contentView), - acctLabel.constraint(.trailing, toView: contentView), - acctLabel.constraint(.centerX, toView: contentView) - ]) - - contentView.addSubview(followButton) - followButton.pin(toSize: CGSize(width: 76, height: 24)) - followButton.constrain([ - followButton.constraint(.top, toView: contentView, constant: 159), - followButton.constraint(.centerX, toView: contentView) + followButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(followButton) + NSLayoutConstraint.activate([ + followButton.widthAnchor.constraint(equalToConstant: 76), + followButton.heightAnchor.constraint(equalToConstant: 24) ]) + containerStackView.addArrangedSubview(followButton) } func config(with mastodonUser: MastodonUser) { displayNameLabel.text = mastodonUser.displayName.isEmpty ? mastodonUser.username : mastodonUser.displayName - acctLabel.text = mastodonUser.acct + acctLabel.text = "@" + mastodonUser.acct avatarImageView.af.setImage( withURL: URL(string: mastodonUser.avatar)!, placeholderImage: UIImage.placeholder(color: .systemFill), @@ -153,7 +170,13 @@ extension SearchRecommendAccountsCollectionViewCell { ) { [weak self] _ in guard let self = self else { return } self.headerImageView.addSubview(self.visualEffectView) - self.visualEffectView.pin(top: 0, left: 0, bottom: 0, right: 0) + self.visualEffectView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.visualEffectView.topAnchor.constraint(equalTo: self.headerImageView.topAnchor), + self.visualEffectView.leadingAnchor.constraint(equalTo: self.headerImageView.leadingAnchor), + self.visualEffectView.trailingAnchor.constraint(equalTo: self.headerImageView.trailingAnchor), + self.visualEffectView.bottomAnchor.constraint(equalTo: self.headerImageView.bottomAnchor) + ]) } delegate?.configFollowButton(with: mastodonUser, followButton: followButton) followButton.publisher(for: .touchUpInside) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index d00cb0504..81167ee6e 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -12,7 +12,6 @@ import UIKit class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let backgroundImageView: UIImageView = { let imageView = UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() @@ -20,7 +19,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .systemFont(ofSize: 20, weight: .semibold) - label.translatesAutoresizingMaskIntoConstraints = false label.lineBreakMode = .byTruncatingTail return label }() @@ -29,7 +27,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let label = UILabel() label.textColor = .white label.font = .preferredFont(forTextStyle: .body) - label.translatesAutoresizingMaskIntoConstraints = false return label }() @@ -38,7 +35,6 @@ class SearchRecommendTagsCollectionViewCell: UICollectionViewCell { let image = UIImage(systemName: "flame.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold))!.withRenderingMode(.alwaysTemplate) imageView.image = image imageView.tintColor = .white - imageView.translatesAutoresizingMaskIntoConstraints = false return imageView }() @@ -74,17 +70,58 @@ extension SearchRecommendTagsCollectionViewCell { layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) + backgroundImageView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(backgroundImageView) - backgroundImageView.constrain(toSuperviewEdges: nil) + NSLayoutConstraint.activate([ + backgroundImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + backgroundImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + backgroundImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + backgroundImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) - contentView.addSubview(hashtagTitleLabel) - hashtagTitleLabel.pin(top: 16, left: 16, bottom: nil, right: 42) - contentView.addSubview(peopleLabel) - peopleLabel.pinTopLeft(top: 46, left: 16) + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.distribution = .fill + containerStackView.spacing = 6 + containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) - contentView.addSubview(flameIconView) - flameIconView.pinTopRight(padding: 16) + + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.distribution = .fill + + hashtagTitleLabel.translatesAutoresizingMaskIntoConstraints = false + hashtagTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + horizontalStackView.addArrangedSubview(hashtagTitleLabel) + horizontalStackView.setContentHuggingPriority(.required - 1, for: .vertical) + + flameIconView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(flameIconView) + + + containerStackView.addArrangedSubview(horizontalStackView) + + let peopleHorizontalStackView = UIStackView() + peopleHorizontalStackView.axis = .horizontal + peopleHorizontalStackView.translatesAutoresizingMaskIntoConstraints = false + peopleHorizontalStackView.distribution = .fill + peopleHorizontalStackView.alignment = .top + peopleLabel.translatesAutoresizingMaskIntoConstraints = false + peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + peopleHorizontalStackView.addArrangedSubview(peopleLabel) + + containerStackView.addArrangedSubview(peopleHorizontalStackView) } func config(with tag: Mastodon.Entity.Tag) { diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index e941fa841..f394f09f1 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -23,8 +23,9 @@ extension SearchViewController { hashtagCollectionView.register(SearchRecommendTagsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendTagsCollectionViewCell.self)) hashtagCollectionView.delegate = self + hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(hashtagCollectionView) - hashtagCollectionView.constrain([ + NSLayoutConstraint.activate([ hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) ]) } @@ -39,8 +40,9 @@ extension SearchViewController { accountsCollectionView.register(SearchRecommendAccountsCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SearchRecommendAccountsCollectionViewCell.self)) accountsCollectionView.delegate = self + accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(accountsCollectionView) - accountsCollectionView.constrain([ + NSLayoutConstraint.activate([ accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) ]) } diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 86a27e03d..8eaf36326 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -19,7 +19,8 @@ extension SearchViewController { searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) view.addSubview(searchingTableView) - searchingTableView.constrain([ + searchingTableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), @@ -50,18 +51,23 @@ extension SearchViewController { } func setupSearchHeader() { - searchHeader.addSubview(recentSearchesLabel) - recentSearchesLabel.constrain([ - recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16), - recentSearchesLabel.constraint(.centerY, toView: searchHeader) + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.layoutMargins = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + searchHeader.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: searchHeader.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: searchHeader.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: searchHeader.bottomAnchor) ]) - - searchHeader.addSubview(clearSearchHistoryButton) - recentSearchesLabel.constrain([ - searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16), - clearSearchHistoryButton.constraint(.centerY, toView: searchHeader) - ]) - + recentSearchesLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(recentSearchesLabel) + clearSearchHistoryButton.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(clearSearchHistoryButton) clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside) } } @@ -84,6 +90,7 @@ extension SearchViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } viewModel.searchResultItemDidSelected(item: item, from: self) diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 710808b52..770fb1da7 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -135,14 +135,16 @@ extension SearchViewController { func setupSearchBar() { searchBar.delegate = self view.addSubview(searchBar) - searchBar.constrain([ + searchBar.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ searchBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), searchBar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), searchBar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), ]) - view.addSubview(statusBar) - statusBar.constrain([ + statusBar.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(statusBar) + NSLayoutConstraint.activate([ statusBar.topAnchor.constraint(equalTo: view.topAnchor), statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), @@ -151,8 +153,9 @@ extension SearchViewController { } func setupScrollView() { + scrollView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(scrollView) - scrollView.constrain([ + NSLayoutConstraint.activate([ scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), @@ -160,8 +163,9 @@ extension SearchViewController { scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), ]) + stackView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(stackView) - stackView.constrain([ + NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 1d87629ba..27c322c88 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -237,10 +237,10 @@ final class SearchViewModel: NSObject { .sink { completion in switch completion { case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) promise(.failure(error)) case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendHashTags request success", (#file as NSString).lastPathComponent, #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) promise(.success(())) } } receiveValue: { [weak self] accounts in diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 5a258d8a5..9339e6f2e 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -55,19 +55,39 @@ final class SearchingTableViewCell: UITableViewCell { extension SearchingTableViewCell { private func configure() { backgroundColor = .clear - selectionStyle = .none - contentView.addSubview(_imageView) - _imageView.pin(toSize: CGSize(width: 42, height: 42)) - _imageView.constrain([ - _imageView.constraint(.leading, toView: contentView, constant: 21), - _imageView.constraint(.centerY, toView: contentView) + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) ]) - contentView.addSubview(_titleLabel) - _titleLabel.pin(top: 12, left: 75, bottom: nil, right: 0) + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42), + _imageView.heightAnchor.constraint(equalToConstant: 42), + ]) - contentView.addSubview(_subTitleLabel) - _subTitleLabel.pin(top: 34, left: 75, bottom: nil, right: 0) + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.translatesAutoresizingMaskIntoConstraints = false + _titleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(_titleLabel) + _subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(_subTitleLabel) + + containerStackView.addArrangedSubview(textStackView) } func config(with account: Mastodon.Entity.Account) { diff --git a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift index bc5bd7663..3db8c2800 100644 --- a/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift +++ b/Mastodon/Scene/Search/View/SearchRecommendCollectionHeader.swift @@ -47,14 +47,35 @@ extension SearchRecommendCollectionHeader { private func configure() { backgroundColor = .clear translatesAutoresizingMaskIntoConstraints = false - addSubview(titleLabel) - titleLabel.pinTopLeft(top: 31, left: 16) - addSubview(descriptionLabel) - descriptionLabel.constrain(toSuperviewEdges: UIEdgeInsets(top: 60, left: 16, bottom: 16, right: 16)) + let containerStackView = UIStackView() + containerStackView.axis = .vertical + containerStackView.layoutMargins = UIEdgeInsets(top: 31, left: 16, bottom: 16, right: 16) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) - addSubview(seeAllButton) - seeAllButton.pinTopRight(top: 26, right: 16) + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.alignment = .center + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.distribution = .fill + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + horizontalStackView.addArrangedSubview(titleLabel) + seeAllButton.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.addArrangedSubview(seeAllButton) + + containerStackView.addArrangedSubview(horizontalStackView) + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(descriptionLabel) + } } From c814065edb9583f55a56d668dd9be329d16049c0 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 12:39:16 +0800 Subject: [PATCH 269/400] fix: Scroll view has ambiguous scrollable content height and ViewDebug warning --- .../SearchViewController+Searching.swift | 20 +++++++------------ .../SearchingTableViewCell.swift | 1 + 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewController+Searching.swift b/Mastodon/Scene/Search/SearchViewController+Searching.swift index 8eaf36326..0602ac200 100644 --- a/Mastodon/Scene/Search/SearchViewController+Searching.swift +++ b/Mastodon/Scene/Search/SearchViewController+Searching.swift @@ -15,17 +15,18 @@ import UIKit extension SearchViewController { func setupSearchingTableView() { - searchingTableView.delegate = self searchingTableView.register(SearchingTableViewCell.self, forCellReuseIdentifier: String(describing: SearchingTableViewCell.self)) searchingTableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + searchingTableView.estimatedRowHeight = 66 + searchingTableView.rowHeight = 66 view.addSubview(searchingTableView) + searchingTableView.delegate = self searchingTableView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - searchingTableView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor) + searchingTableView.topAnchor.constraint(equalTo: searchBar.bottomAnchor), + searchingTableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + searchingTableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + searchingTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) searchingTableView.tableFooterView = UIView() viewModel.isSearching @@ -81,13 +82,6 @@ extension SearchViewController { // MARK: - UITableViewDelegate extension SearchViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - 66 - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 66 - } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) diff --git a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift index 9339e6f2e..a3a7b58ac 100644 --- a/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift +++ b/Mastodon/Scene/Search/TableViewCell/SearchingTableViewCell.swift @@ -86,6 +86,7 @@ extension SearchingTableViewCell { textStackView.addArrangedSubview(_titleLabel) _subTitleLabel.translatesAutoresizingMaskIntoConstraints = false textStackView.addArrangedSubview(_subTitleLabel) + _subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) containerStackView.addArrangedSubview(textStackView) } From bb03c10ef6a3a3486bba33db7c5ff550293a0b51 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 17:00:51 +0800 Subject: [PATCH 270/400] chore: apply review suggestions --- Mastodon.xcodeproj/project.pbxproj | 4 + .../Section/NotificationSection.swift | 62 ++++++--------- .../Mastodon+Entity+Notification+Type.swift | 75 +++++++++++++++++++ .../NotificationViewController.swift | 6 +- ...hRecommendAccountsCollectionViewCell.swift | 10 ++- ...earchRecommendTagsCollectionViewCell.swift | 13 +--- 6 files changed, 112 insertions(+), 58 deletions(-) create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 659d0d734..6c6369835 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */; }; @@ -518,6 +519,7 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; 2DE0FAC72615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendAccountsCollectionViewCell.swift; sourceTree = ""; }; @@ -1450,6 +1452,7 @@ 5DDDF1982617447F00311060 /* Mastodon+Entity+Tag.swift */, 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, + 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -2455,6 +2458,7 @@ DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, + 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */, DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index e7c96139e..81732c608 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -33,39 +33,15 @@ extension NotificationSection { case .notification(let objectID): let notification = managedObjectContext.object(with: objectID) as! MastodonNotification - let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) - + guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else { + assertionFailure() + return nil + } let timeText = notification.createAt.shortTimeAgoSinceNow - var actionText: String - var actionImageName: String - var color: UIColor - switch type { - case .follow: - actionText = L10n.Scene.Notification.Action.follow - actionImageName = "person.crop.circle.badge.checkmark" - color = Asset.Colors.brandBlue.color - case .favourite: - actionText = L10n.Scene.Notification.Action.favourite - actionImageName = "star.fill" - color = Asset.Colors.Notification.favourite.color - case .reblog: - actionText = L10n.Scene.Notification.Action.reblog - actionImageName = "arrow.2.squarepath" - color = Asset.Colors.Notification.reblog.color - case .mention: - actionText = L10n.Scene.Notification.Action.mention - actionImageName = "at" - color = Asset.Colors.Notification.mention.color - case .poll: - actionText = L10n.Scene.Notification.Action.poll - actionImageName = "list.bullet" - color = Asset.Colors.brandBlue.color - default: - actionText = "" - actionImageName = "" - color = .clear - } + let actionText = type.actionText + let actionImageName = type.actionImageName + let color = type.color if let status = notification.status { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell @@ -87,11 +63,13 @@ extension NotificationSection { cell.actionImageBackground.backgroundColor = color cell.actionLabel.text = actionText + " · " + timeText cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName - cell.avatatImageView.af.setImage( - withURL: URL(string: notification.account.avatar)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) + if let url = notification.account.avatarImageURL() { + cell.avatatImageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } cell.avatatImageView.gesture().sink { [weak cell] _ in cell?.delegate?.userAvatarDidPressed(notification: notification) } @@ -113,11 +91,13 @@ extension NotificationSection { cell.actionImageBackground.backgroundColor = color cell.actionLabel.text = actionText + " · " + timeText cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName - cell.avatatImageView.af.setImage( - withURL: URL(string: notification.account.avatar)!, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) + if let url = notification.account.avatarImageURL() { + cell.avatatImageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } cell.avatatImageView.gesture().sink { [weak cell] _ in cell?.delegate?.userAvatarDidPressed(notification: notification) } diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift new file mode 100644 index 000000000..77a7b412e --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -0,0 +1,75 @@ +// +// Mastodon+Entity+Notification+Type.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/19. +// + +import Foundation +import MastodonSDK +import UIKit + +extension Mastodon.Entity.Notification.NotificationType { + public var color: UIColor { + get { + var color: UIColor + switch self { + case .follow: + color = Asset.Colors.brandBlue.color + case .favourite: + color = Asset.Colors.Notification.favourite.color + case .reblog: + color = Asset.Colors.Notification.reblog.color + case .mention: + color = Asset.Colors.Notification.mention.color + case .poll: + color = Asset.Colors.brandBlue.color + default: + color = .clear + } + return color + } + } + + public var actionText: String { + get { + var actionText: String + switch self { + case .follow: + actionText = L10n.Scene.Notification.Action.follow + case .favourite: + actionText = L10n.Scene.Notification.Action.favourite + case .reblog: + actionText = L10n.Scene.Notification.Action.reblog + case .mention: + actionText = L10n.Scene.Notification.Action.mention + case .poll: + actionText = L10n.Scene.Notification.Action.poll + default: + actionText = "" + } + return actionText + } + } + + public var actionImageName: String { + get { + var actionImageName: String + switch self { + case .follow: + actionImageName = "person.crop.circle.badge.checkmark" + case .favourite: + actionImageName = "star.fill" + case .reblog: + actionImageName = "arrow.2.squarepath" + case .mention: + actionImageName = "at" + case .poll: + actionImageName = "list.bullet" + default: + actionImageName = "" + } + return actionImageName + } + } +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 90d72afa5..dd04118db 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -22,7 +22,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { let segmentControl: UISegmentedControl = { let control = UISegmentedControl(items: [L10n.Scene.Notification.Title.everything, L10n.Scene.Notification.Title.mentions]) - control.selectedSegmentIndex = 0 + control.selectedSegmentIndex = NotificationViewModel.NotificationSegment.EveryThing.rawValue return control }() @@ -45,7 +45,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = Asset.Colors.Background.systemBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -115,7 +115,7 @@ extension NotificationViewController { guard let domain = viewModel.activeMastodonAuthenticationBox.value?.domain, let userID = viewModel.activeMastodonAuthenticationBox.value?.userID else { return } - if sender.selectedSegmentIndex == 0 { + if sender.selectedSegmentIndex == NotificationViewModel.NotificationSegment.EveryThing.rawValue { viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID) } else { viewModel.notificationPredicate.value = MastodonNotification.predicate(domain: domain, userID: userID, typeRaw: Mastodon.Entity.Notification.NotificationType.mention.rawValue) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index fdb1af562..9d6bbedc5 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -62,6 +62,7 @@ class SearchRecommendAccountsCollectionViewCell: UICollectionViewCell { let followButton: HighlightDimmableButton = { let button = HighlightDimmableButton(type: .custom) + button.setInsets(forContentPadding: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16), imageTitlePadding: 0) button.setTitleColor(.white, for: .normal) button.setTitle(L10n.Scene.Search.Recommend.Accounts.follow, for: .normal) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold) @@ -97,7 +98,10 @@ extension SearchRecommendAccountsCollectionViewCell { headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) } - + override open func layoutSubviews() { + super.layoutSubviews() + followButton.layer.cornerRadius = followButton.frame.height/2 + } private func configure() { headerImageView.backgroundColor = Asset.Colors.brandBlue.color layer.cornerRadius = 10 @@ -149,8 +153,8 @@ extension SearchRecommendAccountsCollectionViewCell { followButton.translatesAutoresizingMaskIntoConstraints = false containerStackView.addArrangedSubview(followButton) NSLayoutConstraint.activate([ - followButton.widthAnchor.constraint(equalToConstant: 76), - followButton.heightAnchor.constraint(equalToConstant: 24) + followButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76), + followButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 24) ]) containerStackView.addArrangedSubview(followButton) } diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index 81167ee6e..abcd9d08d 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -91,8 +91,7 @@ extension SearchRecommendTagsCollectionViewCell { NSLayoutConstraint.activate([ containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) ]) @@ -111,17 +110,9 @@ extension SearchRecommendTagsCollectionViewCell { containerStackView.addArrangedSubview(horizontalStackView) - - let peopleHorizontalStackView = UIStackView() - peopleHorizontalStackView.axis = .horizontal - peopleHorizontalStackView.translatesAutoresizingMaskIntoConstraints = false - peopleHorizontalStackView.distribution = .fill - peopleHorizontalStackView.alignment = .top peopleLabel.translatesAutoresizingMaskIntoConstraints = false peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) - peopleHorizontalStackView.addArrangedSubview(peopleLabel) - - containerStackView.addArrangedSubview(peopleHorizontalStackView) + containerStackView.addArrangedSubview(peopleLabel) } func config(with tag: Mastodon.Entity.Tag) { From f7aa5c123d98bc6a089ab30939c32631d2a01dc5 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 17:50:58 +0800 Subject: [PATCH 271/400] fix: compose scene layout issue when presenting reply post --- .../Section/ComposeStatusSection.swift | 4 +- .../Diffiable/Section/StatusSection.swift | 6 +- Mastodon/Extension/NSLayoutConstraint.swift | 5 ++ ...iedToStatusContentCollectionViewCell.swift | 8 +-- .../Scene/Compose/ComposeViewController.swift | 6 +- .../View/Container/PlayerContainerView.swift | 2 + .../Scene/Share/View/Content/StatusView.swift | 55 +++++++++++++------ 7 files changed, 59 insertions(+), 27 deletions(-) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 0e7c574b4..d42caa404 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -81,10 +81,10 @@ extension ComposeStatusSection { managedObjectContext.perform { guard let replyToStatusObjectID = replyToStatusObjectID, let replyTo = managedObjectContext.object(with: replyToStatusObjectID) as? Status else { - cell.statusView.headerContainerStackView.isHidden = true + cell.statusView.headerContainerView.isHidden = true return } - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = L10n.Scene.Compose.replyingToUser(replyTo.author.displayNameWithFallback) } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index fc7b02c21..816f852ea 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -525,7 +525,7 @@ extension StatusSection { status: Status ) { if status.reblog != nil { - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) cell.statusView.headerInfoLabel.text = { let author = status.author @@ -533,7 +533,7 @@ extension StatusSection { return L10n.Common.Controls.Status.userReblogged(name) }() } else if status.inReplyToID != nil { - cell.statusView.headerContainerStackView.isHidden = false + cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) cell.statusView.headerInfoLabel.text = { guard let replyTo = status.replyTo else { @@ -544,7 +544,7 @@ extension StatusSection { return L10n.Common.Controls.Status.userRepliedTo(name) }() } else { - cell.statusView.headerContainerStackView.isHidden = true + cell.statusView.headerContainerView.isHidden = true } } diff --git a/Mastodon/Extension/NSLayoutConstraint.swift b/Mastodon/Extension/NSLayoutConstraint.swift index cae353187..eea697e2b 100644 --- a/Mastodon/Extension/NSLayoutConstraint.swift +++ b/Mastodon/Extension/NSLayoutConstraint.swift @@ -12,4 +12,9 @@ extension NSLayoutConstraint { self.priority = priority return self } + + func identifier(_ identifier: String?) -> Self { + self.identifier = identifier + return self + } } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 506d61391..275f545df 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -45,16 +45,16 @@ extension ComposeRepliedToStatusContentCollectionViewCell { private func _init() { backgroundColor = .clear + statusView.actionToolbarContainer.isHidden = true + statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) NSLayoutConstraint.activate([ - statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20).identifier("statusView.top to ComposeRepliedToStatusContentCollectionViewCell.contentView.top"), statusView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), - contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 10).identifier("ComposeRepliedToStatusContentCollectionViewCell.contentView.bottom to statusView.bottom"), ]) - - statusView.actionToolbarContainer.isHidden = true } } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index b463f13ac..a18cf9216 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -182,7 +182,7 @@ extension ComposeViewController { ) // respond scrollView overlap change - view.layoutIfNeeded() + //view.layoutIfNeeded() // update layout when keyboard show/dismiss Publishers.CombineLatest4( KeyboardResponderService.shared.isShow.eraseToAnyPublisher(), @@ -210,7 +210,9 @@ extension ComposeViewController { self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom - self.view.layoutIfNeeded() + if self.view.window != nil { + self.view.layoutIfNeeded() + } } self.updateKeyboardBackground(isKeyboardDisplay: isShow) return diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 2c2229466..9c59a7ebd 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -5,6 +5,7 @@ // Created by xiaojian sun on 2021/3/10. // +import os.log import AVKit import UIKit @@ -93,6 +94,7 @@ extension PlayerContainerView { // MARK: - ContentWarningOverlayViewDelegate extension PlayerContainerView: ContentWarningOverlayViewDelegate { func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.playerContainerView(self, contentWarningOverlayViewDidPressed: contentWarningOverlayView) } } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index a58ad1247..40eb05a58 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -29,6 +29,16 @@ final class StatusView: UIView { static let avatarImageCornerRadius: CGFloat = 4 static let avatarToLabelSpacing: CGFloat = 5 static let contentWarningBlurRadius: CGFloat = 12 + static let containerStackViewSpacing: CGFloat = 10 + + weak var delegate: StatusViewDelegate? + private var needsDrawContentOverlay = false + var pollTableViewDataSource: UITableViewDiffableDataSource? + var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! + + let containerStackView = UIStackView() + let headerContainerView = UIView() + let authorContainerView = UIView() static let reblogIconImage: UIImage = { let font = UIFont.systemFont(ofSize: 13, weight: .medium) @@ -53,13 +63,6 @@ final class StatusView: UIView { return attributedString } - weak var delegate: StatusViewDelegate? - private var needsDrawContentOverlay = false - var pollTableViewDataSource: UITableViewDiffableDataSource? - var pollTableViewHeightLaoutConstraint: NSLayoutConstraint! - - let headerContainerStackView = UIStackView() - let headerIconLabel: UILabel = { let label = UILabel() label.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) @@ -221,9 +224,9 @@ extension StatusView { func _init() { // container: [reblog | author | status | action toolbar] - let containerStackView = UIStackView() + // note: do not set spacing for nested stackView to avoid SDK layout conflict issue containerStackView.axis = .vertical - containerStackView.spacing = 10 + // containerStackView.spacing = 10 containerStackView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerStackView) NSLayoutConstraint.activate([ @@ -232,17 +235,27 @@ extension StatusView { trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) + containerStackView.setContentHuggingPriority(.required - 1, for: .vertical) // header container: [icon | info] - containerStackView.addArrangedSubview(headerContainerStackView) - headerContainerStackView.spacing = 4 + let headerContainerStackView = UIStackView() + headerContainerStackView.axis = .horizontal headerContainerStackView.addArrangedSubview(headerIconLabel) headerContainerStackView.addArrangedSubview(headerInfoLabel) headerIconLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + headerContainerStackView.translatesAutoresizingMaskIntoConstraints = false + headerContainerView.addSubview(headerContainerStackView) + NSLayoutConstraint.activate([ + headerContainerStackView.topAnchor.constraint(equalTo: headerContainerView.topAnchor), + headerContainerStackView.leadingAnchor.constraint(equalTo: headerContainerView.leadingAnchor), + headerContainerStackView.trailingAnchor.constraint(equalTo: headerContainerView.trailingAnchor), + headerContainerView.bottomAnchor.constraint(equalTo: headerContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(headerContainerView) + // author container: [avatar | author meta container | reveal button] let authorContainerStackView = UIStackView() - containerStackView.addArrangedSubview(authorContainerStackView) authorContainerStackView.axis = .horizontal authorContainerStackView.spacing = StatusView.avatarToLabelSpacing authorContainerStackView.distribution = .fill @@ -306,6 +319,16 @@ extension StatusView { authorContainerStackView.addArrangedSubview(revealContentWarningButton) revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) + authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false + authorContainerView.addSubview(authorContainerStackView) + NSLayoutConstraint.activate([ + authorContainerStackView.topAnchor.constraint(equalTo: authorContainerView.topAnchor), + authorContainerStackView.leadingAnchor.constraint(equalTo: authorContainerView.leadingAnchor), + authorContainerStackView.trailingAnchor.constraint(equalTo: authorContainerView.trailingAnchor), + authorContainerView.bottomAnchor.constraint(equalTo: authorContainerStackView.bottomAnchor, constant: StatusView.containerStackViewSpacing).priority(.defaultHigh), + ]) + containerStackView.addArrangedSubview(authorContainerView) + // status container: [status | image / video | audio | poll | poll status] (overlay with content warning) containerStackView.addArrangedSubview(statusContainerStackView) statusContainerStackView.axis = .vertical @@ -370,7 +393,7 @@ extension StatusView { containerStackView.addArrangedSubview(actionToolbarContainer) actionToolbarContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - headerContainerStackView.isHidden = true + headerContainerView.isHidden = true statusMosaicImageViewContainer.isHidden = true pollTableView.isHidden = true pollStatusStackView.isHidden = true @@ -543,7 +566,7 @@ struct StatusView_Previews: PreviewProvider { .previewDisplayName("Normal") UIViewPreview(width: 375) { let statusView = StatusView() - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false statusView.avatarButton.isHidden = true statusView.avatarStackedContainerButton.isHidden = false statusView.avatarStackedContainerButton.topLeadingAvatarStackedImageView.configure( @@ -570,7 +593,7 @@ struct StatusView_Previews: PreviewProvider { placeholderImage: avatarFlora ) ) - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false let images = MosaicImageView_Previews.images let mosaics = statusView.statusMosaicImageViewContainer.setupImageViews(count: 4, maxHeight: 162) for (i, mosaic) in mosaics.enumerated() { @@ -591,7 +614,7 @@ struct StatusView_Previews: PreviewProvider { placeholderImage: avatarFlora ) ) - statusView.headerContainerStackView.isHidden = false + statusView.headerContainerView.isHidden = false statusView.setNeedsLayout() statusView.layoutIfNeeded() statusView.updateContentWarningDisplay(isHidden: false, animated: false) From da19f8f6418f96145ddd1479e06f821b64766ec3 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 18:06:02 +0800 Subject: [PATCH 272/400] chore: remove Redundant codes --- .../Section/NotificationSection.swift | 353 +----------------- .../Diffiable/Section/StatusSection.swift | 35 +- .../NotificationViewController.swift | 24 ++ .../NotificationStatusTableViewCell.swift | 2 +- .../TableviewCell/StatusTableViewCell.swift | 2 +- .../ViewModel/AudioContainerViewModel.swift | 4 +- 6 files changed, 57 insertions(+), 363 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 81732c608..5ccab431c 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -47,7 +47,7 @@ extension NotificationSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell cell.delegate = delegate let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) - NotificationSection.configure(cell: cell, + StatusSection.configure(cell: cell, dependency: dependency, readableLayoutFrame: frame, timestampUpdatePublisher: timestampUpdatePublisher, @@ -116,354 +116,3 @@ extension NotificationSection { } } -extension NotificationSection { - static func configure( - cell: NotificationStatusTableViewCell, - dependency: NeedsDependency, - readableLayoutFrame: CGRect?, - timestampUpdatePublisher: AnyPublisher, - status: Status, - requestUserID: String, - statusItemAttribute: Item.StatusAttribute - ) { - // setup attribute - statusItemAttribute.setupForStatus(status: status) - - // set header - NotificationSection.configureHeader(cell: cell, status: status) - ManagedObjectObserver.observe(object: status) - .receive(on: DispatchQueue.main) - .sink { _ in - // do nothing - } receiveValue: { change in - guard case .update(let object) = change.changeType, - let newStatus = object as? Status else { return } - NotificationSection.configureHeader(cell: cell, status: newStatus) - } - .store(in: &cell.disposeBag) - - // set name username - cell.statusView.nameLabel.text = { - let author = (status.reblog ?? status).author - return author.displayName.isEmpty ? author.username : author.displayName - }() - cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct - // set avatar - - cell.statusView.avatarButton.isHidden = false - cell.statusView.avatarStackedContainerButton.isHidden = true - cell.statusView.configure(with: AvatarConfigurableViewConfiguration(avatarImageURL: status.author.avatarImageURL())) - - // set text - cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) - - // set status text content warning - let isStatusTextSensitive = statusItemAttribute.isStatusTextSensitive ?? false - let spoilerText = (status.reblog ?? status).spoilerText ?? "" - cell.statusView.isStatusTextSensitive = isStatusTextSensitive - cell.statusView.updateContentWarningDisplay(isHidden: !isStatusTextSensitive) - cell.statusView.contentWarningTitle.text = { - if spoilerText.isEmpty { - return L10n.Common.Controls.Status.statusContentWarning - } else { - return L10n.Common.Controls.Status.statusContentWarning + ": \(spoilerText)" - } - }() - - // prepare media attachments - let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } - - // set image - let mosiacImageViewModel = MosaicImageViewModel(mediaAttachments: mediaAttachments) - let imageViewMaxSize: CGSize = { - let maxWidth: CGFloat = { - // use timelinePostView width as container width - // that width follows readable width and keep constant width after rotate - let containerFrame = readableLayoutFrame ?? cell.statusView.frame - var containerWidth = containerFrame.width - containerWidth -= 10 - containerWidth -= StatusView.avatarImageSize.width - return containerWidth - }() - let scale: CGFloat = { - switch mosiacImageViewModel.metas.count { - case 1: return 1.3 - default: return 0.7 - } - }() - return CGSize(width: maxWidth, height: maxWidth * scale) - }() - if mosiacImageViewModel.metas.count == 1 { - let meta = mosiacImageViewModel.metas[0] - let imageView = cell.statusView.statusMosaicImageViewContainer.setupImageView(aspectRatio: meta.size, maxSize: imageViewMaxSize) - imageView.af.setImage( - withURL: meta.url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } else { - let imageViews = cell.statusView.statusMosaicImageViewContainer.setupImageViews(count: mosiacImageViewModel.metas.count, maxHeight: imageViewMaxSize.height) - for (i, imageView) in imageViews.enumerated() { - let meta = mosiacImageViewModel.metas[i] - imageView.af.setImage( - withURL: meta.url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - } - cell.statusView.statusMosaicImageViewContainer.isHidden = mosiacImageViewModel.metas.isEmpty - let isStatusSensitive = statusItemAttribute.isStatusSensitive ?? false - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive - - // set audio - if let _ = mediaAttachments.filter({ $0.type == .audio }).first { - cell.statusView.audioView.isHidden = false - cell.statusView.audioView.playButton.isSelected = false - cell.statusView.audioView.slider.isEnabled = false - cell.statusView.audioView.slider.setValue(0, animated: false) - } else { - cell.statusView.audioView.isHidden = true - } - - // set GIF & video - let playerViewMaxSize: CGSize = { - let maxWidth: CGFloat = { - // use statusView width as container width - // that width follows readable width and keep constant width after rotate - let containerFrame = readableLayoutFrame ?? cell.statusView.frame - return containerFrame.width - }() - let scale: CGFloat = 1.3 - return CGSize(width: maxWidth, height: maxWidth * scale) - }() - - cell.statusView.playerContainerView.contentWarningOverlayView.blurVisualEffectView.effect = isStatusSensitive ? ContentWarningOverlayView.blurVisualEffect : nil - cell.statusView.playerContainerView.contentWarningOverlayView.vibrancyVisualEffectView.alpha = isStatusSensitive ? 1.0 : 0.0 - cell.statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = isStatusSensitive - - if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, - let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) - { - let parent = cell.delegate?.parent() - let playerContainerView = cell.statusView.playerContainerView - let playerViewController = playerContainerView.setupPlayer( - aspectRatio: videoPlayerViewModel.videoSize, - maxSize: playerViewMaxSize, - parent: parent - ) - playerViewController.player = videoPlayerViewModel.player - playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif - playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) - if videoPlayerViewModel.videoKind == .gif { - playerContainerView.setMediaIndicator(isHidden: false) - } else { - videoPlayerViewModel.timeControlStatus.sink { timeControlStatus in - UIView.animate(withDuration: 0.33) { - switch timeControlStatus { - case .playing: - playerContainerView.setMediaIndicator(isHidden: true) - case .paused, .waitingToPlayAtSpecifiedRate: - playerContainerView.setMediaIndicator(isHidden: false) - @unknown default: - assertionFailure() - } - } - } - .store(in: &cell.disposeBag) - } - playerContainerView.isHidden = false - - } else { - cell.statusView.playerContainerView.playerViewController.player?.pause() - cell.statusView.playerContainerView.playerViewController.player = nil - } - // set poll - let poll = (status.reblog ?? status).poll - NotificationSection.configurePoll( - cell: cell, - poll: poll, - requestUserID: requestUserID, - updateProgressAnimated: false, - timestampUpdatePublisher: timestampUpdatePublisher - ) - if let poll = poll { - ManagedObjectObserver.observe(object: poll) - .sink { _ in - // do nothing - } receiveValue: { change in - guard case .update(let object) = change.changeType, - let newPoll = object as? Poll else { return } - NotificationSection.configurePoll( - cell: cell, - poll: newPoll, - requestUserID: requestUserID, - updateProgressAnimated: true, - timestampUpdatePublisher: timestampUpdatePublisher - ) - } - .store(in: &cell.disposeBag) - } - - // set date - let createdAt = (status.reblog ?? status).createdAt - cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow - timestampUpdatePublisher - .sink { _ in - cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow - } - .store(in: &cell.disposeBag) - } - - static func configureHeader( - cell: NotificationStatusTableViewCell, - status: Status - ) { - if status.reblog != nil { - cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) - cell.statusView.headerInfoLabel.text = { - let author = status.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userReblogged(name) - }() - } else if let replyTo = status.replyTo { - cell.statusView.headerContainerStackView.isHidden = false - cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) - cell.statusView.headerInfoLabel.text = { - let author = replyTo.author - let name = author.displayName.isEmpty ? author.username : author.displayName - return L10n.Common.Controls.Status.userRepliedTo(name) - }() - } else { - cell.statusView.headerContainerStackView.isHidden = true - } - } - - static func configurePoll( - cell: NotificationStatusTableViewCell, - poll: Poll?, - requestUserID: String, - updateProgressAnimated: Bool, - timestampUpdatePublisher: AnyPublisher - ) { - guard let poll = poll, - let managedObjectContext = poll.managedObjectContext - else { - cell.statusView.pollTableView.isHidden = true - cell.statusView.pollStatusStackView.isHidden = true - cell.statusView.pollVoteButton.isHidden = true - return - } - - cell.statusView.pollTableView.isHidden = false - cell.statusView.pollStatusStackView.isHidden = false - cell.statusView.pollVoteCountLabel.text = { - if poll.multiple { - let count = poll.votersCount?.intValue ?? 0 - if count > 1 { - return L10n.Common.Controls.Status.Poll.VoterCount.single(count) - } else { - return L10n.Common.Controls.Status.Poll.VoterCount.multiple(count) - } - } else { - let count = poll.votesCount.intValue - if count > 1 { - return L10n.Common.Controls.Status.Poll.VoteCount.single(count) - } else { - return L10n.Common.Controls.Status.Poll.VoteCount.multiple(count) - } - } - }() - if poll.expired { - cell.pollCountdownSubscription = nil - cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.closed - } else if let expiresAt = poll.expiresAt { - cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) - cell.pollCountdownSubscription = timestampUpdatePublisher - .sink { _ in - cell.statusView.pollCountdownLabel.text = L10n.Common.Controls.Status.Poll.timeLeft(expiresAt.shortTimeAgoSinceNow) - } - } else { - // assertionFailure() - cell.pollCountdownSubscription = nil - cell.statusView.pollCountdownLabel.text = "-" - } - - cell.statusView.pollTableView.allowsSelection = !poll.expired - - let votedOptions = poll.options.filter { option in - (option.votedBy ?? Set()).map(\.id).contains(requestUserID) - } - let didVotedLocal = !votedOptions.isEmpty - let didVotedRemote = (poll.votedBy ?? Set()).map(\.id).contains(requestUserID) - cell.statusView.pollVoteButton.isEnabled = didVotedLocal - cell.statusView.pollVoteButton.isHidden = !poll.multiple ? true : (didVotedRemote || poll.expired) - - cell.statusView.pollTableViewDataSource = PollSection.tableViewDiffableDataSource( - for: cell.statusView.pollTableView, - managedObjectContext: managedObjectContext - ) - - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - - let pollItems = poll.options - .sorted(by: { $0.index.intValue < $1.index.intValue }) - .map { option -> PollItem in - let attribute: PollItem.Attribute = { - let selectState: PollItem.Attribute.SelectState = { - // check didVotedRemote later to make the local change possible - if !votedOptions.isEmpty { - return votedOptions.contains(option) ? .on : .off - } else if poll.expired { - return .none - } else if didVotedRemote, votedOptions.isEmpty { - return .none - } else { - return .off - } - }() - let voteState: PollItem.Attribute.VoteState = { - var needsReveal: Bool - if poll.expired { - needsReveal = true - } else if didVotedRemote { - needsReveal = true - } else { - needsReveal = false - } - guard needsReveal else { return .hidden } - let percentage: Double = { - guard poll.votesCount.intValue > 0 else { return 0.0 } - return Double(option.votesCount?.intValue ?? 0) / Double(poll.votesCount.intValue) - }() - let voted = votedOptions.isEmpty ? true : votedOptions.contains(option) - return .reveal(voted: voted, percentage: percentage, animated: updateProgressAnimated) - }() - return PollItem.Attribute(selectState: selectState, voteState: voteState) - }() - let option = PollItem.opion(objectID: option.objectID, attribute: attribute) - return option - } - snapshot.appendItems(pollItems, toSection: .main) - cell.statusView.pollTableViewDataSource?.apply(snapshot, animatingDifferences: false, completion: nil) - } - - static func configureEmptyStateHeader( - cell: TimelineHeaderTableViewCell, - attribute: Item.EmptyStateHeaderAttribute - ) { - cell.timelineHeaderView.iconImageView.image = attribute.reason.iconImage - cell.timelineHeaderView.messageLabel.text = attribute.reason.message - } -} - -extension NotificationSection { - private static func formattedNumberTitleForActionButton(_ number: Int?) -> String { - guard let number = number, number > 0 else { return "" } - return String(number) - } -} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 36d4853a8..e01276e8b 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -10,6 +10,12 @@ import CoreData import CoreDataStack import os.log import UIKit +import AVKit + +protocol StatusCell : DisposeBagCollectable { + var statusView: StatusView { get } + var pollCountdownSubscription: AnyCancellable? { get set } +} enum StatusSection: Equatable, Hashable { case main @@ -127,7 +133,7 @@ extension StatusSection { extension StatusSection { static func configure( - cell: StatusTableViewCell, + cell: StatusCell, dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, @@ -260,14 +266,27 @@ extension StatusSection { if let videoAttachment = mediaAttachments.filter({ $0.type == .gifv || $0.type == .video }).first, let videoPlayerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: videoAttachment) { - let parent = cell.delegate?.parent() + var parent: UIViewController? + var playerViewControllerDelegate: AVPlayerViewControllerDelegate? = nil + switch cell { + case is StatusTableViewCell: + let statusTableViewCell = cell as! StatusTableViewCell + parent = statusTableViewCell.delegate?.parent() + playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate + case is NotificationTableViewCell: + let notificationTableViewCell = cell as! NotificationTableViewCell + parent = notificationTableViewCell.delegate?.parent() + default: + parent = nil + assertionFailure("unknown cell") + } let playerContainerView = cell.statusView.playerContainerView let playerViewController = playerContainerView.setupPlayer( aspectRatio: videoPlayerViewModel.videoSize, maxSize: playerViewMaxSize, parent: parent ) - playerViewController.delegate = cell.delegate?.playerViewControllerDelegate + playerViewController.delegate = playerViewControllerDelegate playerViewController.player = videoPlayerViewModel.player playerViewController.showsPlaybackControls = videoPlayerViewModel.videoKind != .gif playerContainerView.setMediaKind(kind: videoPlayerViewModel.videoKind) @@ -325,7 +344,9 @@ extension StatusSection { StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) // separator line - cell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + if let statusTableViewCell = cell as? StatusTableViewCell { + statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden + } // set date let createdAt = (status.reblog ?? status).createdAt @@ -388,7 +409,7 @@ extension StatusSection { static func configureHeader( - cell: StatusTableViewCell, + cell: StatusCell, status: Status ) { if status.reblog != nil { @@ -416,7 +437,7 @@ extension StatusSection { } static func configureActionToolBar( - cell: StatusTableViewCell, + cell: StatusCell, status: Status, requestUserID: String ) { @@ -447,7 +468,7 @@ extension StatusSection { } static func configurePoll( - cell: StatusTableViewCell, + cell: StatusCell, poll: Poll?, requestUserID: String, updateProgressAnimated: Bool, diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index dd04118db..ad9a7472e 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -131,9 +131,33 @@ extension NotificationViewController { } } +extension NotificationViewController { + func cacheTableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + let key = item.hashValue + let frame = cell.frame + viewModel.cellFrameCache.setObject(NSValue(cgRect: frame), forKey: NSNumber(value: key)) + } + + func handleTableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard let diffableDataSource = viewModel.diffableDataSource else { return UITableView.automaticDimension } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return UITableView.automaticDimension } + guard let frame = viewModel.cellFrameCache.object(forKey: NSNumber(value: item.hashValue))?.cgRectValue else { + if case .bottomLoader = item { + return TimelineLoaderTableViewCell.cellHeight + } else { + return UITableView.automaticDimension + } + } + + return ceil(frame.height) + } +} // MARK: - UITableViewDelegate extension NotificationViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) guard let diffableDataSource = viewModel.diffableDataSource else { return } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index aae4c9e2f..871adcaeb 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -9,7 +9,7 @@ import Combine import Foundation import UIKit -final class NotificationStatusTableViewCell: UITableViewCell { +final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { static let actionImageBorderWidth: CGFloat = 2 static let statusPadding = UIEdgeInsets(top: 50, left: 73, bottom: 24, right: 24) var disposeBag = Set() diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index afa044b67..88004afa2 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -46,7 +46,7 @@ extension StatusTableViewCellDelegate { } } -final class StatusTableViewCell: UITableViewCell { +final class StatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 diff --git a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift index 56bf0cbc3..2bc6db226 100644 --- a/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/AudioContainerViewModel.swift @@ -11,7 +11,7 @@ import UIKit class AudioContainerViewModel { static func configure( - cell: StatusTableViewCell, + cell: StatusCell, audioAttachment: Attachment, audioService: AudioPlaybackService ) { @@ -51,7 +51,7 @@ class AudioContainerViewModel { } static func observePlayer( - cell: StatusTableViewCell, + cell: StatusCell, audioAttachment: Attachment, audioService: AudioPlaybackService ) { From eb89ff6bdc7d40e7c21490642ab076057c0d5cfb Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 19 Apr 2021 18:16:28 +0800 Subject: [PATCH 273/400] fix: rename NotificationTableViewCell to NotificationStatusTableViewCell --- Mastodon/Diffiable/Section/StatusSection.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index e01276e8b..cd4230dab 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -273,8 +273,8 @@ extension StatusSection { let statusTableViewCell = cell as! StatusTableViewCell parent = statusTableViewCell.delegate?.parent() playerViewControllerDelegate = statusTableViewCell.delegate?.playerViewControllerDelegate - case is NotificationTableViewCell: - let notificationTableViewCell = cell as! NotificationTableViewCell + case is NotificationStatusTableViewCell: + let notificationTableViewCell = cell as! NotificationStatusTableViewCell parent = notificationTableViewCell.delegate?.parent() default: parent = nil From 81a1028f20043ef543d10c5ce5e1a200c649e217 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 18:33:11 +0800 Subject: [PATCH 274/400] feat: pause video playback when set reveal state to false --- Mastodon/Diffiable/Item/Item.swift | 2 +- .../Diffiable/Section/StatusSection.swift | 19 +++++++++---------- ...Provider+StatusTableViewCellDelegate.swift | 5 ++--- .../StatusProvider/StatusProviderFacade.swift | 6 ++++++ .../Container/MosaicImageViewContainer.swift | 2 ++ .../View/Container/PlayerContainerView.swift | 1 + .../Content/ContentWarningOverlayView.swift | 14 +++++++++++++- .../TableviewCell/StatusTableViewCell.swift | 4 ++++ 8 files changed, 38 insertions(+), 15 deletions(-) diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index cb01ccdcf..e169be66f 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -39,7 +39,7 @@ extension Item { var isSeparatorLineHidden: Bool let isImageLoaded = CurrentValueSubject(false) - let isMediaRevealing = CurrentValueSubject(false) + let isRevealing = CurrentValueSubject(false) init(isSeparatorLineHidden: Bool = false) { self.isSeparatorLineHidden = isSeparatorLineHidden diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 816f852ea..a432bf067 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -235,7 +235,7 @@ extension StatusSection { } Publishers.CombineLatest( statusItemAttribute.isImageLoaded, - statusItemAttribute.isMediaRevealing + statusItemAttribute.isRevealing ) .receive(on: DispatchQueue.main) .sink { isImageLoaded, isMediaRevealing in @@ -430,15 +430,16 @@ extension StatusSection { statusView.revealContentWarningButton.isHidden = false statusView.contentWarningOverlayView.isHidden = false statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = true + statusView.playerContainerView.contentWarningOverlayView.isHidden = true if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { statusView.updateRevealContentWarningButton(isRevealing: true) statusView.updateContentWarningDisplay(isHidden: true, animated: animated) - attribute.isMediaRevealing.value = true + attribute.isRevealing.value = true } else { statusView.updateRevealContentWarningButton(isRevealing: false) statusView.updateContentWarningDisplay(isHidden: false, animated: animated) - attribute.isMediaRevealing.value = false + attribute.isRevealing.value = false } case .media(let isSensitive): if !isSensitive, documentStore.defaultRevealStatusDict[status.id] == nil { @@ -460,17 +461,15 @@ extension StatusSection { return false }() - attribute.isMediaRevealing.value = needsReveal + attribute.isRevealing.value = needsReveal if needsReveal { statusView.updateRevealContentWarningButton(isRevealing: true) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = nil - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 0.0 - statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = false + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: true, style: .visualEffectView) } else { statusView.updateRevealContentWarningButton(isRevealing: false) - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.blurVisualEffectView.effect = ContentWarningOverlayView.blurVisualEffect - statusView.statusMosaicImageViewContainer.contentWarningOverlayView.vibrancyVisualEffectView.alpha = 1.0 - statusView.statusMosaicImageViewContainer.isUserInteractionEnabled = true + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) + statusView.playerContainerView.contentWarningOverlayView.update(isRevealing: false, style: .visualEffectView) } } if animated { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 8d9687777..198f0a4a3 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -63,12 +63,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { - contentWarningOverlayView.isUserInteractionEnabled = false - statusTableViewCell(cell, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } func statusTableViewCell(_ cell: StatusTableViewCell, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 17d2fbe4e..75efcd36e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -449,6 +449,12 @@ extension StatusProviderFacade { provider.context.documentStore.defaultRevealStatusDict[status.id] = false status.update(isReveal: !isRevealing) status.reblog?.update(isReveal: !isRevealing) + + // pause video playback if isRevealing before toggle + if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, + let playerViewModel = provider.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .video { + playerViewModel.pause() + } } .map { result in return status diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 641050bc2..54e25ed87 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -150,6 +150,7 @@ extension MosaicImageViewContainer { blurhashOverlayImageView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor), ]) + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: imageView.topAnchor), @@ -281,6 +282,7 @@ extension MosaicImageViewContainer { ]) } + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: container.topAnchor), diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index 9c59a7ebd..a6fd4406a 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -71,6 +71,7 @@ extension PlayerContainerView { mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 37c3c7747..a695e1c19 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -70,7 +70,7 @@ class ContentWarningOverlayView: UIView { extension ContentWarningOverlayView { private func _init() { backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false + isUserInteractionEnabled = true // visual effect style // add blur visual effect view in the setup method @@ -169,6 +169,18 @@ extension ContentWarningOverlayView { } } + func update(isRevealing: Bool, style: Style) { + switch style { + case .visualEffectView: + blurVisualEffectView.effect = isRevealing ? nil : ContentWarningOverlayView.blurVisualEffect + vibrancyVisualEffectView.alpha = isRevealing ? 0 : 1 + isUserInteractionEnabled = !isRevealing + case .blurContentImageView: + assertionFailure("not handle here") + break + } + } + } extension ContentWarningOverlayView { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d219daddd..7184b7670 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -73,8 +73,10 @@ final class StatusTableViewCell: UITableViewCell { super.prepareForReuse() selectionStyle = .default statusView.updateContentWarningDisplay(isHidden: true, animated: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() + statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true statusView.playerContainerView.isHidden = true threadMetaView.isHidden = true disposeBag.removeAll() @@ -94,6 +96,8 @@ final class StatusTableViewCell: UITableViewCell { override func layoutSubviews() { super.layoutSubviews() + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { self.statusView.drawContentWarningImageView() } From 4041929b3e9a55c53f1c366f71cea65afe16faec Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 19:13:20 +0800 Subject: [PATCH 275/400] feat: fulfill the content warning when compose reply post --- Mastodon/Diffiable/Section/ComposeStatusSection.swift | 1 + Mastodon/Scene/Compose/ComposeViewModel.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index d42caa404..91363ef09 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -77,6 +77,7 @@ extension ComposeStatusSection { return cell case .input(let replyToStatusObjectID, let attribute): let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self), for: indexPath) as! ComposeStatusContentCollectionViewCell + cell.statusContentWarningEditorView.textView.text = attribute.contentWarningContent.value cell.textEditorView.text = attribute.composeContent.value ?? "" managedObjectContext.perform { guard let replyToStatusObjectID = replyToStatusObjectID, diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index ef744d0b3..587b56f23 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -112,6 +112,10 @@ final class ComposeViewModel { for acct in mentionAccts { UITextChecker.learnWord(acct) } + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + self.isContentWarningComposing.value = true + self.composeStatusAttribute.contentWarningContent.value = spoilerText + } let initialComposeContent = mentionAccts.joined(separator: " ") let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " From b7258dc7f2b1d6c2f312af0c16a5fa8ed55251f5 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 19 Apr 2021 19:15:49 +0800 Subject: [PATCH 276/400] fix: set reveal button hidden in the compose scene --- .../ComposeRepliedToStatusContentCollectionViewCell.swift | 1 + .../ComposeStatusContentCollectionViewCell.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift index 275f545df..8da4c0729 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeRepliedToStatusContentCollectionViewCell.swift @@ -46,6 +46,7 @@ extension ComposeRepliedToStatusContentCollectionViewCell { backgroundColor = .clear statusView.actionToolbarContainer.isHidden = true + statusView.revealContentWarningButton.isHidden = true statusView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusView) diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift index 2b71e55f3..5ec2a9eeb 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusContentCollectionViewCell.swift @@ -90,6 +90,7 @@ extension ComposeStatusContentCollectionViewCell { textEditorView.changeObserver = self statusContentWarningEditorView.containerView.isHidden = true + statusView.revealContentWarningButton.isHidden = true } } From 04d427ea93cec4a22843950ceacd9278cb3dea4e Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 20 Apr 2021 13:18:27 +0800 Subject: [PATCH 277/400] feat: make content warning works in the notification scene --- .../xcschemes/xcschememanagement.plist | 2 +- .../Diffiable/Item/NotificationItem.swift | 6 +- .../Section/NotificationSection.swift | 23 ++++--- .../NotificationViewController.swift | 23 ++++++- .../NotificationViewModel+diffable.swift | 31 +++++++-- .../NotificationStatusTableViewCell.swift | 63 +++++++++++++++++-- .../NotificationTableViewCell.swift | 5 ++ 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 6ec23cf5d..18c8840d8 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 10 + 13 Mastodon - RTL.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index ba0d0c140..f26d2e43d 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -9,7 +9,7 @@ import CoreData import Foundation enum NotificationItem { - case notification(objectID: NSManagedObjectID) + case notification(objectID: NSManagedObjectID, attribute: Item.StatusAttribute) case bottomLoader } @@ -17,7 +17,7 @@ enum NotificationItem { extension NotificationItem: Equatable { static func == (lhs: NotificationItem, rhs: NotificationItem) -> Bool { switch (lhs, rhs) { - case (.notification(let idLeft), .notification(let idRight)): + case (.notification(let idLeft, _), .notification(let idRight, _)): return idLeft == idRight case (.bottomLoader, .bottomLoader): return true @@ -30,7 +30,7 @@ extension NotificationItem: Equatable { extension NotificationItem: Hashable { func hash(into hasher: inout Hasher) { switch self { - case .notification(let id): + case .notification(let id, _): hasher.combine(id) case .bottomLoader: hasher.combine(String(describing: NotificationItem.bottomLoader.self)) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 5ccab431c..9c59350b4 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -22,15 +22,14 @@ extension NotificationSection { timestampUpdatePublisher: AnyPublisher, managedObjectContext: NSManagedObjectContext, delegate: NotificationTableViewCellDelegate, - dependency: NeedsDependency, - requestUserID: String + dependency: NeedsDependency ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) { [weak delegate, weak dependency] (tableView, indexPath, notificationItem) -> UITableViewCell? in guard let dependency = dependency else { return nil } switch notificationItem { - case .notification(let objectID): + case .notification(let objectID, let attribute): let notification = managedObjectContext.object(with: objectID) as! MastodonNotification guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: notification.typeRaw) else { @@ -46,14 +45,18 @@ extension NotificationSection { if let status = notification.status { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: NotificationStatusTableViewCell.self), for: indexPath) as! NotificationStatusTableViewCell cell.delegate = delegate + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) - StatusSection.configure(cell: cell, - dependency: dependency, - readableLayoutFrame: frame, - timestampUpdatePublisher: timestampUpdatePublisher, - status: status, - requestUserID: requestUserID, - statusItemAttribute: Item.StatusAttribute(isStatusTextSensitive: false, isStatusSensitive: false)) + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: frame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) timestampUpdatePublisher .sink { _ in let timeText = notification.createAt.shortTimeAgoSinceNow diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index ad9a7472e..57b5dc639 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -36,6 +36,7 @@ final class NotificationViewController: UIViewController, NeedsDependency { tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) tableView.tableFooterView = UIView() tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.backgroundColor = .clear return tableView }() @@ -45,13 +46,14 @@ final class NotificationViewController: UIViewController, NeedsDependency { extension NotificationViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color navigationItem.titleView = segmentControl segmentControl.addTarget(self, action: #selector(NotificationViewController.segmentedControlValueChanged(_:)), for: .valueChanged) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), @@ -65,6 +67,7 @@ extension NotificationViewController { viewModel.contentOffsetAdjustableTimelineViewControllerDelegate = self viewModel.setupDiffableDataSource(for: tableView, delegate: self, dependency: self) viewModel.viewDidLoad.send() + // bind refresh control viewModel.isFetchingLatestNotification .receive(on: DispatchQueue.main) @@ -83,6 +86,8 @@ extension NotificationViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } @@ -159,11 +164,10 @@ extension NotificationViewController { extension NotificationViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) guard let diffableDataSource = viewModel.diffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } switch item { - case .notification(let objectID): + case .notification(let objectID, _): let notification = context.managedObjectContext.object(with: objectID) as! MastodonNotification if let status = notification.status { let viewModel = ThreadViewModel(context: context, optionalStatus: status) @@ -199,6 +203,7 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl } } +// MARK: - NotificationTableViewCellDelegate extension NotificationViewController: NotificationTableViewCellDelegate { func userAvatarDidPressed(notification: MastodonNotification) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) @@ -210,6 +215,18 @@ extension NotificationViewController: NotificationTableViewCellDelegate { func parent() -> UIViewController { self } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: self, cell: cell) + } } // MARK: - UIScrollViewDelegate diff --git a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift index 5bd2d92dd..cd28c5f5a 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+diffable.swift @@ -20,16 +20,13 @@ extension NotificationViewModel { .autoconnect() .share() .eraseToAnyPublisher() - guard let userid = activeMastodonAuthenticationBox.value?.userID else { - return - } + diffableDataSource = NotificationSection.tableViewDiffableDataSource( for: tableView, timestampUpdatePublisher: timestampUpdatePublisher, managedObjectContext: context.managedObjectContext, delegate: delegate, - dependency: dependency, - requestUserID: userid + dependency: dependency ) } } @@ -67,9 +64,31 @@ extension NotificationViewModel: NSFetchedResultsControllerDelegate { DispatchQueue.main.async { let oldSnapshot = diffableDataSource.snapshot() + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + for item in oldSnapshot.itemIdentifiers { + guard case let .notification(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } var newSnapshot = NSDiffableDataSourceSnapshot() newSnapshot.appendSections([.main]) - newSnapshot.appendItems(notifications.map { NotificationItem.notification(objectID: $0.objectID) }, toSection: .main) + let items: [NotificationItem] = notifications.map { notification in + let attribute: Item.StatusAttribute = oldSnapshotAttributeDict[notification.objectID] ?? Item.StatusAttribute() + +// let attribute: Item.StatusAttribute = { +// if let attribute = oldSnapshotAttributeDict[notification.objectID] { +// return attribute +// } else if let status = notification.status { +// let attribute = Item.StatusAttribute() +// let isSensitive = status.sensitive || !(status.spoilerText ?? "").isEmpty +// attribute.isRevealing.value = !isSensitive +// return attribute +// } else { +// return Item.StatusAttribute() +// } +// }() + return NotificationItem.notification(objectID: notification.objectID, attribute: attribute) + } + newSnapshot.appendItems(items, toSection: .main) if !notifications.isEmpty, self.noMoreNotification.value == false { newSnapshot.appendItems([.bottomLoader], toSection: .main) } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 871adcaeb..7b76dd2f0 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -8,6 +8,7 @@ import Combine import Foundation import UIKit +import ActiveLabel final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { static let actionImageBorderWidth: CGFloat = 2 @@ -78,8 +79,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() statusView.playerContainerView.isHidden = true @@ -99,6 +99,9 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { override func layoutSubviews() { super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { self.statusView.drawContentWarningImageView() } @@ -107,6 +110,8 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { extension NotificationStatusTableViewCell { func configure() { + backgroundColor = Asset.Colors.Background.systemBackground.color + let containerStackView = UIStackView() containerStackView.axis = .horizontal containerStackView.alignment = .top @@ -154,7 +159,6 @@ extension NotificationStatusTableViewCell { actionImageView.centerYAnchor.constraint(equalTo: actionImageBackground.centerYAnchor) ]) - let actionStackView = UIStackView() actionStackView.axis = .horizontal actionStackView.distribution = .fill @@ -187,13 +191,12 @@ extension NotificationStatusTableViewCell { statusBorder.trailingAnchor.constraint(equalTo: statusView.trailingAnchor, constant: 12), ]) + statusView.delegate = self statusStackView.addArrangedSubview(statusBorder) containerStackView.addArrangedSubview(statusStackView) - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color - statusView.isUserInteractionEnabled = false // remove item don't display statusView.actionToolbarContainer.removeFromStackView() // it affect stackView's height,need remove @@ -206,4 +209,54 @@ extension NotificationStatusTableViewCell { statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: highlighted) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + resetContentOverlayBlurImageBackgroundColor(selected: selected) + } + + private func resetContentOverlayBlurImageBackgroundColor(selected: Bool) { + let imageViewBackgroundColor: UIColor? = selected ? selectedBackgroundView?.backgroundColor : backgroundColor + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = imageViewBackgroundColor + } +} + +// MARK: - StatusViewDelegate +extension NotificationStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + // do nothing + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + // do nothing + } + + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, revealContentWarningButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + delegate?.notificationStatusTableViewCell(self, statusView: statusView, playerContainerView: playerContainerView, contentWarningOverlayViewDidPressed: contentWarningOverlayView) + } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + // do nothing + } + + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + // do nothing + } + + } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 60b43ac35..619bffa17 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -16,6 +16,11 @@ protocol NotificationTableViewCellDelegate: AnyObject { func parent() -> UIViewController func userAvatarDidPressed(notification: MastodonNotification) + + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) + } final class NotificationTableViewCell: UITableViewCell { From f6e785a8943c4a93a76ac6c718420d303f2d3223 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 20 Apr 2021 13:40:14 +0800 Subject: [PATCH 278/400] feat: set GIF pause and auto resume when toggle content warning overlay --- .../Diffiable/Section/StatusSection.swift | 1 + .../StatusProvider/StatusProviderFacade.swift | 55 +++++++++++++++---- .../View/Container/PlayerContainerView.swift | 1 + .../Content/ContentWarningOverlayView.swift | 4 ++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 1dd155d5b..4f09142a7 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -469,6 +469,7 @@ extension StatusSection { statusView.revealContentWarningButton.isHidden = false statusView.contentWarningOverlayView.isHidden = true statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isHidden = false + statusView.playerContainerView.contentWarningOverlayView.isHidden = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) func updateContentOverlay() { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 75efcd36e..2e6102227 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -417,26 +417,52 @@ extension StatusProviderFacade { extension StatusProviderFacade { + static func responseToStatusContentWarningRevealAction(dependency: NotificationViewController, cell: UITableViewCell) { + let status = Future { promise in + guard let diffableDataSource = dependency.viewModel.diffableDataSource, + let indexPath = dependency.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + + switch item { + case .notification(let objectID, _): + dependency.viewModel.fetchedResultsController.managedObjectContext.perform { + let notification = dependency.viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! MastodonNotification + promise(.success(notification.status)) + } + default: + promise(.success(nil)) + } + } + + _responseToStatusContentWarningRevealAction( + dependency: dependency, + status: status + ) + } + static func responseToStatusContentWarningRevealAction(provider: StatusProvider, cell: UITableViewCell) { _responseToStatusContentWarningRevealAction( - provider: provider, + dependency: provider, status: provider.status(for: cell, indexPath: nil) ) } - private static func _responseToStatusContentWarningRevealAction(provider: StatusProvider, status: Future) { + private static func _responseToStatusContentWarningRevealAction(dependency: NeedsDependency, status: Future) { status - .compactMap { [weak provider] status -> AnyPublisher? in - guard let provider = provider else { return nil } + .compactMap { [weak dependency] status -> AnyPublisher? in + guard let dependency = dependency else { return nil } guard let _status = status else { return nil } - return provider.context.managedObjectContext.performChanges { - guard let status = provider.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } - let appStartUpTimestamp = provider.context.documentStore.appStartUpTimestamp + return dependency.context.managedObjectContext.performChanges { + guard let status = dependency.context.managedObjectContext.object(with: _status.objectID) as? Status else { return } + let appStartUpTimestamp = dependency.context.documentStore.appStartUpTimestamp let isRevealing: Bool = { - if provider.context.documentStore.defaultRevealStatusDict[status.id] == true { + if dependency.context.documentStore.defaultRevealStatusDict[status.id] == true { return true } - if status.reblog.flatMap({ provider.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { + if status.reblog.flatMap({ dependency.context.documentStore.defaultRevealStatusDict[$0.id] }) == true { return true } if let revealedAt = status.revealedAt, revealedAt > appStartUpTimestamp { @@ -446,15 +472,20 @@ extension StatusProviderFacade { return false }() // toggle reveal - provider.context.documentStore.defaultRevealStatusDict[status.id] = false + dependency.context.documentStore.defaultRevealStatusDict[status.id] = false status.update(isReveal: !isRevealing) status.reblog?.update(isReveal: !isRevealing) // pause video playback if isRevealing before toggle if isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, - let playerViewModel = provider.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .video { + let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment) { playerViewModel.pause() } + // resume GIF playback if NOT isRevealing before toggle + if !isRevealing, let attachment = (status.reblog ?? status).mediaAttachments?.first, + let playerViewModel = dependency.context.videoPlaybackService.dequeueVideoPlayerViewModel(for: attachment), playerViewModel.videoKind == .gif { + playerViewModel.play() + } } .map { result in return status @@ -464,7 +495,7 @@ extension StatusProviderFacade { .sink { _ in // do nothing } - .store(in: &provider.context.disposeBag) + .store(in: &dependency.context.disposeBag) } } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index a6fd4406a..f7a8a1546 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -22,6 +22,7 @@ final class PlayerContainerView: UIView { let contentWarningOverlayView: ContentWarningOverlayView = { let contentWarningOverlayView = ContentWarningOverlayView() + contentWarningOverlayView.update(cornerRadius: PlayerContainerView.cornerRadius) return contentWarningOverlayView }() diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index a695e1c19..f04a56e9e 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -181,6 +181,10 @@ extension ContentWarningOverlayView { } } + func update(cornerRadius: CGFloat) { + blurVisualEffectView.layer.cornerRadius = cornerRadius + } + } extension ContentWarningOverlayView { From 731b49aaa03065f799a4d2dc0e0e6b2b0dd11672 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 20 Apr 2021 15:40:10 +0800 Subject: [PATCH 279/400] chore: suggestion use v2 api --- .../HashtagTimelineViewModel.swift | 2 +- .../Scene/Search/SearchViewController.swift | 6 +-- .../SearchViewModel+LoadOldestState.swift | 10 ++--- Mastodon/Scene/Search/SearchViewModel.swift | 14 +++---- .../APIService/APIService+Recommend.swift | 11 ++--- .../APIService/APIService+Search.swift | 4 +- ...rch.swift => Mastodon+API+V2+Search.swift} | 8 ++-- .../API/Mastodon+API+V2+Suggestions.swift | 41 +++++++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 7 +++- .../Entity/Mastodon+Entity+Suggestion.swift | 23 +++++++++++ 10 files changed, 98 insertions(+), 28 deletions(-) rename MastodonSDK/Sources/MastodonSDK/API/{Mastodon+API+Search.swift => Mastodon+API+V2+Search.swift} (96%) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift index b43b67143..54acbf4fe 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel.swift @@ -84,7 +84,7 @@ final class HashtagTimelineViewModel: NSObject { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let query = Mastodon.API.Search.Query(q: hashtag, type: .hashtags) + let query = Mastodon.API.V2.Search.Query(q: hashtag, type: .hashtags) context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { _ in diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 770fb1da7..357f142c8 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -217,11 +217,11 @@ extension SearchViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { switch selectedScope { case 0: - viewModel.searchScope.value = Mastodon.API.Search.SearchType.default + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.default case 1: - viewModel.searchScope.value = Mastodon.API.Search.SearchType.accounts + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.accounts case 2: - viewModel.searchScope.value = Mastodon.API.Search.SearchType.hashtags + viewModel.searchScope.value = Mastodon.API.V2.Search.SearchType.hashtags default: break } diff --git a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift index b486df774..4fe68e47d 100644 --- a/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Search/SearchViewModel+LoadOldestState.swift @@ -53,14 +53,14 @@ extension SearchViewModel.LoadOldestState { } var offset = 0 switch viewModel.searchScope.value { - case Mastodon.API.Search.SearchType.accounts: + case Mastodon.API.V2.Search.SearchType.accounts: offset = oldSearchResult.accounts.count - case Mastodon.API.Search.SearchType.hashtags: + case Mastodon.API.V2.Search.SearchType.hashtags: offset = oldSearchResult.hashtags.count default: return } - let query = Mastodon.API.Search.Query(q: viewModel.searchText.value, + let query = Mastodon.API.V2.Search.Query(q: viewModel.searchText.value, type: viewModel.searchScope.value, accountID: nil, maxID: nil, @@ -82,7 +82,7 @@ extension SearchViewModel.LoadOldestState { } } receiveValue: { result in switch viewModel.searchScope.value { - case Mastodon.API.Search.SearchType.accounts: + case Mastodon.API.V2.Search.SearchType.accounts: if result.value.accounts.isEmpty { stateMachine.enter(NoMore.self) } else { @@ -93,7 +93,7 @@ extension SearchViewModel.LoadOldestState { viewModel.searchResult.value = Mastodon.Entity.SearchResult(accounts: newAccounts, statuses: oldSearchResult.statuses, hashtags: oldSearchResult.hashtags) stateMachine.enter(Idle.self) } - case Mastodon.API.Search.SearchType.hashtags: + case Mastodon.API.V2.Search.SearchType.hashtags: if result.value.hashtags.isEmpty { stateMachine.enter(NoMore.self) } else { diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 27c322c88..afdc1a9f4 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -26,7 +26,7 @@ final class SearchViewModel: NSObject { // output let searchText = CurrentValueSubject("") - let searchScope = CurrentValueSubject(Mastodon.API.Search.SearchType.default) + let searchScope = CurrentValueSubject(Mastodon.API.V2.Search.SearchType.default) let isSearching = CurrentValueSubject(false) @@ -86,7 +86,7 @@ final class SearchViewModel: NSObject { } .flatMap { (text, scope) -> AnyPublisher, Error> in - let query = Mastodon.API.Search.Query(q: text, + let query = Mastodon.API.V2.Search.Query(q: text, type: scope, accountID: nil, maxID: nil, @@ -130,8 +130,8 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.mixed]) searchHistories.forEach { searchHistory in - let containsAccount = scope == Mastodon.API.Search.SearchType.accounts || scope == Mastodon.API.Search.SearchType.default - let containsHashTag = scope == Mastodon.API.Search.SearchType.hashtags || scope == Mastodon.API.Search.SearchType.default + let containsAccount = scope == Mastodon.API.V2.Search.SearchType.accounts || scope == Mastodon.API.V2.Search.SearchType.default + let containsHashTag = scope == Mastodon.API.V2.Search.SearchType.hashtags || scope == Mastodon.API.V2.Search.SearchType.default if let mastodonUser = searchHistory.account, containsAccount { let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID) snapshot.appendItems([item], toSection: .mixed) @@ -186,7 +186,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.account]) let items = accounts.compactMap { SearchResultItem.account(account: $0) } snapshot.appendItems(items, toSection: .account) - if self.searchScope.value == Mastodon.API.Search.SearchType.accounts, !items.isEmpty { + if self.searchScope.value == Mastodon.API.V2.Search.SearchType.accounts, !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .account) } } @@ -194,7 +194,7 @@ final class SearchViewModel: NSObject { snapshot.appendSections([.hashtag]) let items = tags.compactMap { SearchResultItem.hashtag(tag: $0) } snapshot.appendItems(items, toSection: .hashtag) - if self.searchScope.value == Mastodon.API.Search.SearchType.hashtags, !items.isEmpty { + if self.searchScope.value == Mastodon.API.V2.Search.SearchType.hashtags, !items.isEmpty { snapshot.appendItems([.bottomLoader], toSection: .hashtag) } } @@ -245,7 +245,7 @@ final class SearchViewModel: NSObject { } } receiveValue: { [weak self] accounts in guard let self = self else { return } - let ids = accounts.value.compactMap({$0.id}) + let ids = accounts.value.compactMap({$0.account.id}) let userFetchRequest = MastodonUser.sortedFetchRequest userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) let mastodonUsers: [MastodonUser]? = { diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 1c58fc575..134d43fab 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -17,21 +17,22 @@ extension APIService { domain: String, query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) - .flatMap { response -> AnyPublisher, Error> in + return Mastodon.API.V2.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in let log = OSLog.api return self.backgroundManagedObjectContext.performChanges { - response.value.forEach { user in + response.value.forEach { suggestionAccount in + let user = suggestionAccount.account let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) let flag = isCreated ? "+" : "-" os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) } } .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.V2.SuggestionAccount]> in switch result { case .success: return response diff --git a/Mastodon/Service/APIService/APIService+Search.swift b/Mastodon/Service/APIService/APIService+Search.swift index ba40aa5de..986e3d931 100644 --- a/Mastodon/Service/APIService/APIService+Search.swift +++ b/Mastodon/Service/APIService/APIService+Search.swift @@ -13,11 +13,11 @@ extension APIService { func search( domain: String, - query: Mastodon.API.Search.Query, + query: Mastodon.API.V2.Search.Query, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - return Mastodon.API.Search.search(session: session, domain: domain, query: query, authorization: authorization) + return Mastodon.API.V2.Search.search(session: session, domain: domain, query: query, authorization: authorization) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift similarity index 96% rename from MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift rename to MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift index be8bb2607..c0a687f17 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Search.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Search.swift @@ -8,7 +8,7 @@ import Combine import Foundation -extension Mastodon.API.Search { +extension Mastodon.API.V2.Search { static func searchURL(domain: String) -> URL { Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("search") } @@ -32,7 +32,7 @@ extension Mastodon.API.Search { public static func search( session: URLSession, domain: String, - query: Mastodon.API.Search.Query, + query: Mastodon.API.V2.Search.Query, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( @@ -49,7 +49,7 @@ extension Mastodon.API.Search { } } -extension Mastodon.API.Search { +extension Mastodon.API.V2.Search { public struct Query: Codable, GetQuery { public init(q: String, @@ -105,7 +105,7 @@ extension Mastodon.API.Search { } } -public extension Mastodon.API.Search { +public extension Mastodon.API.V2.Search { enum SearchType: String, Codable { case accounts case hashtags diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift new file mode 100644 index 000000000..9e6876b41 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+V2+Suggestions.swift @@ -0,0 +1,41 @@ +// +// Mastodon+API+V2+Suggestions.swift +// +// +// Created by sxiaojian on 2021/4/20. +// + +import Combine +import Foundation + +extension Mastodon.API.V2.Suggestions { + static func suggestionsURL(domain: String) -> URL { + Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("suggestions") + } + + /// Follow suggestions, No document for now + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - authorization: User token. + /// - Returns: `AnyPublisher` contains `AccountsSuggestion` nested in the response + public static func get( + session: URLSession, + domain: String, + query: Mastodon.API.Suggestions.Query?, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: suggestionsURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.V2.SuggestionAccount].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..921cc9ed3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -99,6 +99,7 @@ extension Mastodon.API { } extension Mastodon.API { + public enum V2 { } public enum Account { } public enum App { } public enum CustomEmojis { } @@ -111,13 +112,17 @@ extension Mastodon.API { public enum Reblog { } public enum Statuses { } public enum Timeline { } - public enum Search { } public enum Trends { } public enum Suggestions { } public enum Notifications { } public enum Subscriptions { } } +extension Mastodon.API.V2 { + public enum Search { } + public enum Suggestions { } +} + extension Mastodon.API { static func get( diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift new file mode 100644 index 000000000..98d67d5fa --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Suggestion.swift @@ -0,0 +1,23 @@ +// +// Mastodon+Entity+Suggestion.swift +// +// +// Created by sxiaojian on 2021/4/20. +// + +import Foundation + +extension Mastodon.Entity.V2 { + + public struct SuggestionAccount: Codable { + + public let source: String + public let account: Mastodon.Entity.Account + + + enum CodingKeys: String, CodingKey { + case source + case account + } + } +} From e7cd130bf13cc00fb84aeddea4257be3f255f960 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 20 Apr 2021 16:46:04 +0800 Subject: [PATCH 280/400] fix: L18n repair --- Localization/app.json | 3 ++- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 0aa622718..a7b98e0ee 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -329,7 +329,7 @@ }, "favorite": { "title": "Your Favorites" - }, + }, "notification": { "title": { "Everything": "Everything", @@ -341,6 +341,7 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" + }, }, "thread": { "back_title": "Post", diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 253b65d95..cc7eef062 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -227,4 +227,4 @@ any server."; "Scene.Thread.Reblog.Single" = "%@ reblog"; "Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file From c8474c6a7fe5c82f842ac359f86e94f9e3fc2bb9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 14:46:31 +0800 Subject: [PATCH 281/400] feature: suggestion account scene --- Localization/app.json | 8 +- Mastodon.xcodeproj/project.pbxproj | 28 +++ Mastodon/Coordinator/SceneCoordinator.swift | 11 ++ .../Section/RecommendAccountSection.swift | 17 ++ Mastodon/Generated/Strings.swift | 10 ++ .../UserProvider/UserProviderFacade.swift | 3 +- .../Resources/en.lproj/Localizable.strings | 4 + .../HomeTimelineViewController.swift | 64 +++++++ Mastodon/Scene/Search/SearchViewModel.swift | 2 +- .../SuggestionAccountViewController.swift | 161 ++++++++++++++++++ .../SuggestionAccountViewModel.swift | 101 +++++++++++ .../SuggestionAccountTableViewCell.swift | 149 ++++++++++++++++ .../APIService/APIService+Follow.swift | 21 ++- .../APIService/APIService+Recommend.swift | 35 +++- 14 files changed, 601 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift create mode 100644 Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift create mode 100644 Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift diff --git a/Localization/app.json b/Localization/app.json index a7b98e0ee..7c72f7949 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,9 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "find_people": "Find people to follow", + "manually_search": "Manually search instead" }, "status": { "user_reblogged": "%s reblogged", @@ -230,6 +232,10 @@ "Publishing": "Publishing post..." } }, + "suggestion_account": { + "title": "Find People to Follow", + "follow_explain": "When you follow someone, you’ll see their posts in your home feed." + }, "public_timeline": { "title": "Public" }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7b147991f..93ae2ba09 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -120,6 +120,9 @@ 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04325CA52B200804E11 /* TimelineLoaderTableViewCell.swift */; }; 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */; }; 2DA7D05725CA693F00804E11 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA7D05625CA693F00804E11 /* Application.swift */; }; + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */; }; + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */; }; + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */; }; 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */; }; 2DCB73FD2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */; }; 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */; }; @@ -530,6 +533,9 @@ 2DA7D04925CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBottomLoaderTableViewCell.swift; sourceTree = ""; }; 2DA7D05025CA545E00804E11 /* LoadMoreConfigurableTableViewContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreConfigurableTableViewContainer.swift; sourceTree = ""; }; 2DA7D05625CA693F00804E11 /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewController.swift; sourceTree = ""; }; + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountViewModel.swift; sourceTree = ""; }; + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountTableViewCell.swift; sourceTree = ""; }; 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Notification+Type.swift"; sourceTree = ""; }; 2DCB73FC2615C13900EC03D4 /* SearchRecommendCollectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecommendCollectionHeader.swift; sourceTree = ""; }; 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendHashTagSection.swift; sourceTree = ""; }; @@ -1182,6 +1188,24 @@ path = Decoration; sourceTree = ""; }; + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */ = { + isa = PBXGroup; + children = ( + 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, + 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, + ); + path = SuggestionAccount; + sourceTree = ""; + }; + 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */ = { + isa = PBXGroup; + children = ( + 2DAC9E45262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift */, + ); + path = TableViewCell; + sourceTree = ""; + }; 2DE0FAC62615F5D200CDF649 /* View */ = { isa = PBXGroup; children = ( @@ -1669,6 +1693,7 @@ 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, + 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, DB9D6BFD25E4F57B0051B173 /* Notification */, @@ -2385,6 +2410,7 @@ DBCBED1726132DB500B49291 /* UserTimelineViewModel+Diffable.swift in Sources */, DB71FD4C25F8C80E00512AE1 /* StatusPrefetchingService.swift in Sources */, 2DE0FACE2615F7AD00CDF649 /* RecommendAccountSection.swift in Sources */, + 2DAC9E3E262FC2400062E1A6 /* SuggestionAccountViewModel.swift in Sources */, DB49A62B25FF36C700B98345 /* APIService+CustomEmoji.swift in Sources */, DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, @@ -2425,6 +2451,7 @@ DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, + 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -2503,6 +2530,7 @@ 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, + 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..c0ad695f0 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -13,6 +13,7 @@ final public class SceneCoordinator { private weak var scene: UIScene! private weak var sceneDelegate: SceneDelegate! private weak var appContext: AppContext! + private weak var tabBarController: MainTabBarController! let id = UUID().uuidString @@ -61,6 +62,8 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // suggestion account + case suggestionAccount(viewModel: SuggestionAccountViewModel) // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -93,6 +96,7 @@ extension SceneCoordinator { func setup() { let viewController = MainTabBarController(context: appContext, coordinator: self) sceneDelegate.window?.rootViewController = viewController + tabBarController = viewController } func setupOnboardingIfNeeds(animated: Bool) { @@ -187,6 +191,9 @@ extension SceneCoordinator { return viewController } + func switchToTabBar(tab: MainTabBarController.Tab) { + tabBarController.selectedIndex = tab.rawValue + } } private extension SceneCoordinator { @@ -246,6 +253,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .suggestionAccount(let viewModel): + let _viewController = SuggestionAccountViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 3ecd4e3b2..1732be29f 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -29,4 +29,21 @@ extension RecommendAccountSection { return cell } } + + static func tableViewDiffableDataSource( + for tableView: UITableView, + managedObjectContext: NSManagedObjectContext, + viewModel: SuggestionAccountViewModel, + delegate: SuggestionAccountTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel,weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in + guard let viewModel = viewModel else { return nil } + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell + let user = managedObjectContext.object(with: objectID) as! MastodonUser + let isSelected = viewModel.selectedAccounts.contains(objectID) + cell.delegate = delegate + cell.config(with: user, isSelected: isSelected) + return cell + } + } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b7bd3d0a8..aa841bbfc 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -72,6 +72,10 @@ internal enum L10n { internal static let done = L10n.tr("Localizable", "Common.Controls.Actions.Done") /// Edit internal static let edit = L10n.tr("Localizable", "Common.Controls.Actions.Edit") + /// Find people to follow + internal static let findPeople = L10n.tr("Localizable", "Common.Controls.Actions.FindPeople") + /// Manually search instead + internal static let manuallySearch = L10n.tr("Localizable", "Common.Controls.Actions.ManuallySearch") /// OK internal static let ok = L10n.tr("Localizable", "Common.Controls.Actions.Ok") /// Open in Safari @@ -675,6 +679,12 @@ internal enum L10n { } } } + internal enum SuggestionAccount { + /// When you follow someone, you’ll see their posts in your home feed. + internal static let followExplain = L10n.tr("Localizable", "Scene.SuggestionAccount.FollowExplain") + /// Find People to Follow + internal static let title = L10n.tr("Localizable", "Scene.SuggestionAccount.Title") + } internal enum Thread { /// Post internal static let backTitle = L10n.tr("Localizable", "Scene.Thread.BackTitle") diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5f4dd32f..b64bfe79d 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,7 +44,8 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: true ) } .switchToLatest() diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index cc7eef062..c90c013da 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -20,6 +20,8 @@ Please check your internet connection."; "Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; "Common.Controls.Actions.Ok" = "OK"; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; @@ -220,6 +222,8 @@ any server."; "Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; "Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; "Scene.Settings.Title" = "Settings"; +"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; +"Scene.SuggestionAccount.Title" = "Find People to Follow"; "Scene.Thread.BackTitle" = "Post"; "Scene.Thread.Favorite.Multiple" = "%@ favorites"; "Scene.Thread.Favorite.Single" = "%@ favorite"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..60fa3c9c4 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -23,6 +23,15 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + lazy var emptyView: UIStackView = { + let emptyView = UIStackView() + emptyView.axis = .vertical + emptyView.distribution = .fill + emptyView.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 54, right: 20) + emptyView.isLayoutMarginsRelativeArrangement = true + return emptyView + }() + let titleView = HomeTimelineNavigationBarTitleView() let settingBarButtonItem: UIBarButtonItem = { @@ -142,6 +151,13 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() + } completion: { [weak self] _ in + guard let self = self else { return } + if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { + self.showEmptyView() + } else { + self.emptyView.removeFromSuperview() + } } } } @@ -217,6 +233,54 @@ extension HomeTimelineViewController { } extension HomeTimelineViewController { + func showEmptyView() { + if emptyView.superview != nil { + return + } + view.addSubview(emptyView) + emptyView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) + ]) + + let findPeopleButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.findPeopleButtonPressed(_:)), for: .touchUpInside) + return button + }() + NSLayoutConstraint.activate([ + findPeopleButton.heightAnchor.constraint(equalToConstant: 46) + ]) + + let manuallySearchButton: HighlightDimmableButton = { + let button = HighlightDimmableButton() + button.titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold)) + button.setTitle(L10n.Common.Controls.Actions.manuallySearch, for: .normal) + button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) + button.addTarget(self, action: #selector(HomeTimelineViewController.manuallySearchButtonPressed(_:)), for: .touchUpInside) + return button + }() + + emptyView.addArrangedSubview(findPeopleButton) + emptyView.setCustomSpacing(17, after: findPeopleButton) + emptyView.addArrangedSubview(manuallySearchButton) + + } +} + +extension HomeTimelineViewController { + + @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { + let viewModel = SuggestionAccountViewModel(context: context) + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func manuallySearchButtonPressed(_ sender: UIButton) { + coordinator.switchToTabBar(tab: .search) + } @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index afdc1a9f4..1a1d87fc9 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -233,7 +233,7 @@ final class SearchViewModel: NSObject { promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) return } - self.context.apiService.recommendAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { case .failure(let error): diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift new file mode 100644 index 000000000..16a916db4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -0,0 +1,161 @@ +// +// SuggestionAccountViewController.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import OSLog +import UIKit + +class SuggestionAccountViewController: UIViewController, NeedsDependency { + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + + var viewModel: SuggestionAccountViewModel! + + let tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.tableFooterView = UIView() + tableView.separatorStyle = .singleLine + tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + return tableView + }() + + lazy var tableHeader: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) + return view + }() + + let followExplainLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.SuggestionAccount.followExplain + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + let avatarStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .equalSpacing + stackView.alignment = .center + stackView.spacing = 15 + return stackView + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", (#file as NSString).lastPathComponent, #line, #function) + } +} + +extension SuggestionAccountViewController { + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Asset.Colors.Background.systemBackground.color + title = L10n.Scene.SuggestionAccount.title + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + viewModel.diffableDataSource = RecommendAccountSection.tableViewDiffableDataSource( + for: tableView, + managedObjectContext: context.managedObjectContext, + viewModel: viewModel, + delegate: self + ) + + viewModel.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let self = self else { return } + self.setupHeader(accounts: accounts) + } + .store(in: &disposeBag) + } + + func setupHeader(accounts: [NSManagedObjectID]) { + if accounts.isEmpty { + return + } + followExplainLabel.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(followExplainLabel) + NSLayoutConstraint.activate([ + followExplainLabel.topAnchor.constraint(equalTo: tableHeader.topAnchor, constant: 20), + followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), + ]) + + avatarStackView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(avatarStackView) + NSLayoutConstraint.activate([ + avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + ]) + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) + let count = min(avatarImageViewCount, accounts.count) + for i in 0 ..< count { + let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser + let imageView = UIImageView() + imageView.layer.cornerRadius = 6 + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), + ]) + if let url = account.avatarImageURL() { + imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + avatarStackView.addArrangedSubview(imageView) + } + + tableView.tableHeaderView = tableHeader + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { + let selected = !sender.isSelected + sender.isSelected = !sender.isSelected + if selected { + viewModel.selectedAccounts.append(objectID) + } else { + viewModel.selectedAccounts.removeAll { $0 == objectID } + } + } +} + +extension SuggestionAccountViewController { + @objc func doneButtonDidClick(_ sender: UIButton) { + dismiss(animated: true, completion: nil) + viewModel.followAction() + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift new file mode 100644 index 000000000..9a92b059e --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -0,0 +1,101 @@ +// +// SuggestionAccountViewModel.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Combine +import CoreData +import CoreDataStack +import GameplayKit +import MastodonSDK +import os.log +import UIKit + +final class SuggestionAccountViewModel: NSObject { + var disposeBag = Set() + + // input + let context: AppContext + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = [NSManagedObjectID]() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { + self.context = context + if let accounts = accounts { + self.accounts.value = accounts + } + super.init() + + self.accounts + .receive(on: DispatchQueue.main) + .sink { [weak self] accounts in + guard let dataSource = self?.diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(accounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + .store(in: &disposeBag) + + if accounts == nil || (accounts ?? []).isEmpty { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + + context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + let ids = response.value.map(\.account.id) + let users: [MastodonUser]? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let accounts = users?.map(\.objectID) { + self.accounts.value = accounts + } + } + .store(in: &disposeBag) + } + } + + func followAction() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + for objectID in selectedAccounts { + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: false + ) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { _ in + } + .store(in: &disposeBag) + } + } +} diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift new file mode 100644 index 000000000..9dafaedb8 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -0,0 +1,149 @@ +// +// SuggestionAccountTableViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/21. +// + +import Foundation +import UIKit +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +protocol SuggestionAccountTableViewCellDelegate: AnyObject { + func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) +} + +final class SuggestionAccountTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SuggestionAccountTableViewCellDelegate? + + let _imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.primary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + return imageView + }() + + let titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.brandBlue.color + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.lineBreakMode = .byTruncatingTail + return label + }() + + let subTitleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = .preferredFont(forTextStyle: .body) + return label + }() + + lazy var button: HighlightDimmableButton = { + let button = HighlightDimmableButton(type: .custom) + if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(plusImage, for: .normal) + } + if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { + button.setImage(minusImage, for: .selected) + } + button.publisher(for: \.isSelected) + .sink { isSelected in + if isSelected { + button.tintColor = Asset.Colors.danger.color + } else { + button.tintColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &self.disposeBag) + return button + }() + override func prepareForReuse() { + super.prepareForReuse() + _imageView.af.cancelImageRequest() + _imageView.image = nil + disposeBag.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountTableViewCell { + private func configure() { + backgroundColor = .clear + + let containerStackView = UIStackView() + containerStackView.axis = .horizontal + containerStackView.distribution = .fill + containerStackView.spacing = 12 + containerStackView.layoutMargins = UIEdgeInsets(top: 12, left: 21, bottom: 12, right: 12) + containerStackView.isLayoutMarginsRelativeArrangement = true + containerStackView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + _imageView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(_imageView) + NSLayoutConstraint.activate([ + _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1) + ]) + + let textStackView = UIStackView() + textStackView.axis = .vertical + textStackView.distribution = .fill + textStackView.alignment = .leading + textStackView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(titleLabel) + subTitleLabel.translatesAutoresizingMaskIntoConstraints = false + textStackView.addArrangedSubview(subTitleLabel) + subTitleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) + + containerStackView.addArrangedSubview(textStackView) + textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + + button.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(button) + } + + func config(with account: MastodonUser, isSelected: Bool) { + if let url = account.avatarImageURL() { + _imageView.af.setImage( + withURL: url, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName + subTitleLabel.text = account.acct + button.isSelected = isSelected + button.publisher(for: .touchUpInside) + .sink { [weak self] sender in + guard let self = self else { return } + self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index cf19878d6..6db612942 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,10 +24,15 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, + needFeedback: Bool ) -> AnyPublisher, Error> { - let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + var impactFeedbackGenerator: UIImpactFeedbackGenerator? + var notificationFeedbackGenerator: UINotificationFeedbackGenerator? + if needFeedback { + impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + notificationFeedbackGenerator = UINotificationFeedbackGenerator() + } return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -35,9 +40,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator.prepare() + impactFeedbackGenerator?.prepare() } receiveOutput: { _ in - impactFeedbackGenerator.impactOccurred() + impactFeedbackGenerator?.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -74,13 +79,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator.prepare() - notificationFeedbackGenerator.notificationOccurred(.error) + notificationFeedbackGenerator?.prepare() + notificationFeedbackGenerator?.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator.notificationOccurred(.success) + notificationFeedbackGenerator?.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+Recommend.swift b/Mastodon/Service/APIService/APIService+Recommend.swift index 134d43fab..a3bcb3e35 100644 --- a/Mastodon/Service/APIService/APIService+Recommend.swift +++ b/Mastodon/Service/APIService/APIService+Recommend.swift @@ -13,7 +13,38 @@ import CoreDataStack import OSLog extension APIService { - func recommendAccount( + func suggestionAccount( + domain: String, + query: Mastodon.API.Suggestions.Query?, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Suggestions.get(session: session, domain: domain, query: query, authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let log = OSLog.api + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { user in + let (mastodonUser,isCreated) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: user, userCache: nil, networkDate: Date(), log: log) + let flag = isCreated ? "+" : "-" + os_log(.info, log: log, "%{public}s[%{public}ld], %{public}s: fetch mastodon user [%s](%s)%s", (#file as NSString).lastPathComponent, #line, #function, flag, mastodonUser.id, mastodonUser.username) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[Mastodon.Entity.Account]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func suggestionAccountV2( domain: String, query: Mastodon.API.Suggestions.Query?, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox @@ -44,7 +75,7 @@ extension APIService { } .eraseToAnyPublisher() } - + func recommendTrends( domain: String, query: Mastodon.API.Trends.Query? From 776263aaf2f37179f19b6b2d49a60e46d38b7902 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 17:58:56 +0800 Subject: [PATCH 282/400] chore: compatible with the old server --- .../HomeTimelineViewController.swift | 5 + ...hRecommendAccountsCollectionViewCell.swift | 5 +- .../SearchViewController+Recommend.swift | 8 +- Mastodon/Scene/Search/SearchViewModel.swift | 125 +++++++++++++----- .../SuggestionAccountViewController.swift | 18 +-- .../SuggestionAccountViewModel.swift | 101 ++++++++++---- .../SuggestionAccountTableViewCell.swift | 7 +- 7 files changed, 189 insertions(+), 80 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 60fa3c9c4..74a6ae004 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -159,6 +159,8 @@ extension HomeTimelineViewController { self.emptyView.removeFromSuperview() } } + } else { + self.emptyView.removeFromSuperview() } } .store(in: &disposeBag) @@ -245,6 +247,9 @@ extension HomeTimelineViewController { emptyView.bottomAnchor.constraint(equalTo: view.readableContentGuide.bottomAnchor) ]) + if emptyView.arrangedSubviews.count > 0 { + return + } let findPeopleButton: PrimaryActionButton = { let button = PrimaryActionButton() button.setTitle(L10n.Common.Controls.Actions.findPeople, for: .normal) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift index 9d6bbedc5..289583aec 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendAccountsCollectionViewCell.swift @@ -98,10 +98,7 @@ extension SearchRecommendAccountsCollectionViewCell { headerImageView.layer.borderColor = Asset.Colors.Border.searchCard.color.cgColor applyShadow(color: Asset.Colors.Shadow.searchCard.color, alpha: 0.1, x: 0, y: 3, blur: 12, spread: 0) } - override open func layoutSubviews() { - super.layoutSubviews() - followButton.layer.cornerRadius = followButton.frame.height/2 - } + private func configure() { headerImageView.backgroundColor = Asset.Colors.brandBlue.color layer.cornerRadius = 10 diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index f394f09f1..b5ff0e54b 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -101,5 +101,11 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { extension SearchViewController { @objc func hashtagSeeAllButtonPressed(_ sender: UIButton) {} - @objc func accountSeeAllButtonPressed(_ sender: UIButton) {} + @objc func accountSeeAllButtonPressed(_ sender: UIButton) { + if self.viewModel.recommendAccounts.isEmpty { + return + } + let viewModel = SuggestionAccountViewModel(context: context, accounts: self.viewModel.recommendAccounts) + coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) + } } diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 1a1d87fc9..04a977e0d 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -34,6 +34,7 @@ final class SearchViewModel: NSObject { var recommendHashTags = [Mastodon.Entity.Tag]() var recommendAccounts = [NSManagedObjectID]() + var recommendAccountsFallback = PassthroughSubject() var hashtagDiffableDataSource: UICollectionViewDiffableDataSource? var accountDiffableDataSource: UICollectionViewDiffableDataSource? @@ -87,15 +88,15 @@ final class SearchViewModel: NSObject { .flatMap { (text, scope) -> AnyPublisher, Error> in let query = Mastodon.API.V2.Search.Query(q: text, - type: scope, - accountID: nil, - maxID: nil, - minID: nil, - excludeUnreviewed: nil, - resolve: nil, - limit: nil, - offset: nil, - following: nil) + type: scope, + accountID: nil, + maxID: nil, + minID: nil, + excludeUnreviewed: nil, + resolve: nil, + limit: nil, + offset: nil, + following: nil) return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) } .sink { _ in @@ -142,7 +143,6 @@ final class SearchViewModel: NSObject { } } dataSource.apply(snapshot, animatingDifferences: false, completion: nil) - } .store(in: &disposeBag) @@ -161,21 +161,33 @@ final class SearchViewModel: NSObject { } .store(in: &disposeBag) - requestRecommendAccounts() + requestRecommendAccountsV2() .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { - guard let dataSource = self.accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self.applyDataSource() } } receiveValue: { _ in } .store(in: &disposeBag) + recommendAccountsFallback + .sink { [weak self] _ in + guard let self = self else { return } + self.requestRecommendAccounts() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.recommendAccounts.isEmpty { + self.applyDataSource() + } + } receiveValue: { _ in + } + .store(in: &self.disposeBag) + } + .store(in: &disposeBag) + searchResult .receive(on: DispatchQueue.main) .sink { [weak self] searchResult in @@ -227,13 +239,43 @@ final class SearchViewModel: NSObject { } } - func requestRecommendAccounts() -> Future { + func requestRecommendAccountsV2() -> Future { Future { promise in guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) return } self.context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + if let apiError = error as? Mastodon.API.Error { + if apiError.httpResponseStatus == .notFound { + self?.recommendAccountsFallback.send() + } + } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request fail: %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + promise(.failure(error)) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: recommendAccount request success", (#file as NSString).lastPathComponent, #line, #function) + promise(.success(())) + } + } receiveValue: { [weak self] accounts in + guard let self = self else { return } + let ids = accounts.value.compactMap({$0.account.id}) + self.receiveAccounts(ids: ids) + } + .store(in: &self.disposeBag) + } + } + + func requestRecommendAccounts() -> Future { + Future { promise in + guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { + promise(.failure(APIService.APIError.implicit(APIService.APIError.ErrorReason.authenticationMissing))) + return + } + self.context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { completion in switch completion { case .failure(let error): @@ -245,28 +287,43 @@ final class SearchViewModel: NSObject { } } receiveValue: { [weak self] accounts in guard let self = self else { return } - let ids = accounts.value.compactMap({$0.account.id}) - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - let mastodonUsers: [MastodonUser]? = { - let userFetchRequest = MastodonUser.sortedFetchRequest - userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - userFetchRequest.returnsObjectsAsFaults = false - do { - return try self.context.managedObjectContext.fetch(userFetchRequest) - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - if let users = mastodonUsers { - self.recommendAccounts = users.map(\.objectID) - } + let ids = accounts.value.compactMap({$0.id}) + self.receiveAccounts(ids: ids) } .store(in: &self.disposeBag) } } + func applyDataSource() { + guard let dataSource = accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + + func receiveAccounts(ids: [String]) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + let mastodonUsers: [MastodonUser]? = { + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + userFetchRequest.returnsObjectsAsFaults = false + do { + return try self.context.managedObjectContext.fetch(userFetchRequest) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let users = mastodonUsers { + recommendAccounts = users.map(\.objectID) + } + } + func accountCollectionViewItemDidSelected(mastodonUser: MastodonUser, from: UIViewController) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) DispatchQueue.main.async { diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 16a916db4..9cc6f33a2 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -15,11 +15,11 @@ import UIKit class SuggestionAccountViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } - + var disposeBag = Set() - + var viewModel: SuggestionAccountViewModel! - + let tableView: UITableView = { let tableView = ControlContainableTableView() tableView.register(SuggestionAccountTableViewCell.self, forCellReuseIdentifier: String(describing: SuggestionAccountTableViewCell.self)) @@ -29,14 +29,14 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) return tableView }() - + lazy var tableHeader: UIView = { let view = UIView() view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color view.frame = CGRect(origin: .zero, size: CGSize(width: tableView.frame.width, height: 156)) return view }() - + let followExplainLabel: UILabel = { let label = UILabel() label.text = L10n.Scene.SuggestionAccount.followExplain @@ -45,7 +45,7 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { label.numberOfLines = 0 return label }() - + let avatarStackView: UIStackView = { let stackView = UIStackView() stackView.axis = .horizontal @@ -84,7 +84,7 @@ extension SuggestionAccountViewController { viewModel: viewModel, delegate: self ) - + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -105,7 +105,7 @@ extension SuggestionAccountViewController { followExplainLabel.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), ]) - + avatarStackView.translatesAutoresizingMaskIntoConstraints = false tableHeader.addSubview(avatarStackView) NSLayoutConstraint.activate([ @@ -136,7 +136,7 @@ extension SuggestionAccountViewController { } avatarStackView.addArrangedSubview(imageView) } - + tableView.tableHeaderView = tableHeader } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 9a92b059e..33313bade 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -18,64 +18,111 @@ final class SuggestionAccountViewModel: NSObject { // input let context: AppContext - let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = [NSManagedObjectID]() // output - var diffableDataSource: UITableViewDiffableDataSource? + let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + var selectedAccounts = [NSManagedObjectID]() + var suggestionAccountsFallback = PassthroughSubject() + + var diffableDataSource: UITableViewDiffableDataSource? { + didSet(value) { + if !accounts.value.isEmpty { + applyDataSource(accounts: accounts.value) + } + } + } init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { self.context = context - if let accounts = accounts { - self.accounts.value = accounts - } + super.init() self.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in - guard let dataSource = self?.diffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(accounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + self?.applyDataSource(accounts: accounts) } .store(in: &disposeBag) + if let accounts = accounts { + self.accounts.value = accounts + } + if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } context.apiService.suggestionAccountV2(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) - .sink { completion in + .sink { [weak self] completion in switch completion { case .failure(let error): + if let apiError = error as? Mastodon.API.Error { + if apiError.httpResponseStatus == .notFound { + self?.suggestionAccountsFallback.send() + } + } os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccountV2 failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: // handle isFetchingLatestTimeline in fetch controller delegate break } } receiveValue: { [weak self] response in - guard let self = self else { return } let ids = response.value.map(\.account.id) - let users: [MastodonUser]? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - request.returnsObjectsAsFaults = false - do { - return try context.managedObjectContext.fetch(request) - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - if let accounts = users?.map(\.objectID) { - self.accounts.value = accounts - } + self?.receiveAccounts(ids: ids) } .store(in: &disposeBag) + + suggestionAccountsFallback + .sink(receiveValue: { [weak self] _ in + self?.requestSuggestionAccount() + }) + .store(in: &disposeBag) } } + func requestSuggestionAccount() { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + context.apiService.suggestionAccount(domain: activeMastodonAuthenticationBox.domain, query: nil, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: fetch recommendAccount failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // handle isFetchingLatestTimeline in fetch controller delegate + break + } + } receiveValue: { [weak self] response in + let ids = response.value.map(\.id) + self?.receiveAccounts(ids: ids) + } + .store(in: &disposeBag) + } + + func applyDataSource(accounts: [NSManagedObjectID]) { + guard let dataSource = diffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(accounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } + + func receiveAccounts(ids: [String]) { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let users: [MastodonUser]? = { + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + request.returnsObjectsAsFaults = false + do { + return try context.managedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let accounts = users?.map(\.objectID) { + self.accounts.value = accounts + } + } + func followAction() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } for objectID in selectedAccounts { diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 9dafaedb8..256b7babd 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -5,8 +5,6 @@ // Created by sxiaojian on 2021/4/21. // -import Foundation -import UIKit import Combine import CoreData import CoreDataStack @@ -19,7 +17,6 @@ protocol SuggestionAccountTableViewCellDelegate: AnyObject { } final class SuggestionAccountTableViewCell: UITableViewCell { - var disposeBag = Set() weak var delegate: SuggestionAccountTableViewCellDelegate? @@ -65,6 +62,7 @@ final class SuggestionAccountTableViewCell: UITableViewCell { .store(in: &self.disposeBag) return button }() + override func prepareForReuse() { super.prepareForReuse() _imageView.af.cancelImageRequest() @@ -139,11 +137,10 @@ extension SuggestionAccountTableViewCell { subTitleLabel.text = account.acct button.isSelected = isSelected button.publisher(for: .touchUpInside) - .sink { [weak self] sender in + .sink { [weak self] _ in guard let self = self else { return } self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) } .store(in: &disposeBag) } - } From 9402dab97f4c8ab008451e57c4e27e04fe03becf Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 18:08:07 +0800 Subject: [PATCH 283/400] fix: suggestion button tintColor --- .../SuggestionAccountTableViewCell.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 256b7babd..8f564ec31 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -51,15 +51,6 @@ final class SuggestionAccountTableViewCell: UITableViewCell { if let minusImage = UIImage(systemName: "minus.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { button.setImage(minusImage, for: .selected) } - button.publisher(for: \.isSelected) - .sink { isSelected in - if isSelected { - button.tintColor = Asset.Colors.danger.color - } else { - button.tintColor = Asset.Colors.Label.secondary.color - } - } - .store(in: &self.disposeBag) return button }() @@ -142,5 +133,14 @@ extension SuggestionAccountTableViewCell { self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) } .store(in: &disposeBag) + button.publisher(for: \.isSelected) + .sink { [weak self] isSelected in + if isSelected { + self?.button.tintColor = Asset.Colors.danger.color + } else { + self?.button.tintColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &self.disposeBag) } } From 106a5cc71a0e6f00cea042613c34ce144dc946a8 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 21 Apr 2021 18:52:09 +0800 Subject: [PATCH 284/400] fix: homeTimeline refresh after follow people --- .../Scene/HomeTimeline/HomeTimelineViewController.swift | 5 +++++ Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift | 6 ++++++ .../SuggestionAccount/SuggestionAccountViewModel.swift | 6 +++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 74a6ae004..2734b33e1 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -200,6 +200,10 @@ extension HomeTimelineViewController { // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() + + if (viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { + viewModel.loadLatestStateMachine.enter(HomeTimelineViewModel.LoadLatestState.Loading.self) + } } override func viewDidAppear(_ animated: Bool) { @@ -280,6 +284,7 @@ extension HomeTimelineViewController { @objc private func findPeopleButtonPressed(_ sender: PrimaryActionButton) { let viewModel = SuggestionAccountViewModel(context: context) + viewModel.delegate = self.viewModel coordinator.present(scene: .suggestionAccount(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 1c0ddf71b..c154b0508 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -129,3 +129,9 @@ final class HomeTimelineViewModel: NSObject { } } + +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { + func homeTimelineNeedRefresh() { + loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 33313bade..ec52120dc 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -13,12 +13,16 @@ import MastodonSDK import os.log import UIKit +protocol SuggestionAccountViewModelDelegate: AnyObject { + func homeTimelineNeedRefresh() +} final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() // input let context: AppContext + weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) var selectedAccounts = [NSManagedObjectID]() @@ -137,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate + self.delegate?.homeTimelineNeedRefresh() break } } receiveValue: { _ in From a1b19e44f7d5ca373b06e2c2f86f8df49898bf2f Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 21 Apr 2021 23:58:36 +0800 Subject: [PATCH 285/400] chore: add GItHub CI Action for project --- .github/scripts/build.sh | 13 +++++++++++++ .github/scripts/setup.sh | 4 ++++ .github/workflows/main.yml | 27 +++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100755 .github/scripts/build.sh create mode 100755 .github/scripts/setup.sh create mode 100644 .github/workflows/main.yml diff --git a/.github/scripts/build.sh b/.github/scripts/build.sh new file mode 100755 index 000000000..76e65f49f --- /dev/null +++ b/.github/scripts/build.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -eo pipefail + +# build with SwiftPM: +# https://developer.apple.com/documentation/swift_packages/building_swift_packages_or_apps_that_use_them_in_continuous_integration_workflows + +xcodebuild -workspace Mastodon.xcworkspace \ + -scheme Mastodon \ + -disableAutomaticPackageResolution \ + -destination "platform=iOS Simulator,name=iPhone SE (2nd generation)" \ + clean \ + build | xcpretty \ No newline at end of file diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh new file mode 100755 index 000000000..e1411fb50 --- /dev/null +++ b/.github/scripts/setup.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +sudo gem install cocoapods-keys +pod install \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..67670b46c --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: + - master + - develop + - feature/* + pull_request: + branches: + - develop + +# macOS environments: https://github.com/actions/virtual-environments/tree/main/images/macos + +jobs: + build: + name: CI build + runs-on: macos-11.0 + steps: + - name: checkout + uses: actions/checkout@v2 + - name: force Xcode 12.2 + run: sudo xcode-select -switch /Applications/Xcode_12.2.app + - name: setup + run: exec ./.github/scripts/setup.sh + - name: build + run: exec ./.github/scripts/build.sh From b9537e20c46f3c35eb964a2a3a0d5a30071af79c Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 22 Apr 2021 00:03:01 +0800 Subject: [PATCH 286/400] chore: rollback macOS version to 10.15 due to 11.0 marked private preview ref: https://github.com/actions/virtual-environments/issues/2486 --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 67670b46c..e1bc703a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,7 +15,7 @@ on: jobs: build: name: CI build - runs-on: macos-11.0 + runs-on: macos-10.15 steps: - name: checkout uses: actions/checkout@v2 From 46fe59c92016b835bdf5544033adb5d6c4613e0d Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 10:11:19 +0800 Subject: [PATCH 287/400] chore: add debounce for refresh --- .../Scene/HomeTimeline/HomeTimelineViewModel.swift | 14 +++++++++----- .../SuggestionAccountViewModel.swift | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index c154b0508..a81056d0f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -34,6 +34,7 @@ final class HomeTimelineViewModel: NSObject { weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let homeTimelineNeedRefresh = PassthroughSubject() // output // top loader private(set) lazy var loadLatestStateMachine: GKStateMachine = { @@ -122,6 +123,13 @@ final class HomeTimelineViewModel: NSObject { } .store(in: &disposeBag) + homeTimelineNeedRefresh + .debounce(for: 0.3, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) + } + .store(in: &disposeBag) + } deinit { @@ -130,8 +138,4 @@ final class HomeTimelineViewModel: NSObject { } -extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { - func homeTimelineNeedRefresh() { - loadLatestStateMachine.enter(LoadLatestState.Loading.self) - } -} +extension HomeTimelineViewModel: SuggestionAccountViewModelDelegate { } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index ec52120dc..494b00293 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -14,7 +14,7 @@ import os.log import UIKit protocol SuggestionAccountViewModelDelegate: AnyObject { - func homeTimelineNeedRefresh() + var homeTimelineNeedRefresh: PassthroughSubject { get } } final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() @@ -141,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: - self.delegate?.homeTimelineNeedRefresh() + self.delegate?.homeTimelineNeedRefresh.send() break } } receiveValue: { _ in From 7f6e9fb90703587690c3568db1698ee18a9f655a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 10:29:53 +0800 Subject: [PATCH 288/400] fix: suggestions account order --- Mastodon/Scene/Search/SearchViewModel.swift | 5 +++- .../SuggestionAccountViewModel.swift | 25 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 04a977e0d..5f1ff5f46 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -320,7 +320,10 @@ final class SearchViewModel: NSObject { } }() if let users = mastodonUsers { - recommendAccounts = users.map(\.objectID) + let sortedUsers = users.sorted { (user1, user2) -> Bool in + (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + } + recommendAccounts = sortedUsers.map(\.objectID) } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 494b00293..ac8803188 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -16,6 +16,7 @@ import UIKit protocol SuggestionAccountViewModelDelegate: AnyObject { var homeTimelineNeedRefresh: PassthroughSubject { get } } + final class SuggestionAccountViewModel: NSObject { var disposeBag = Set() @@ -110,20 +111,27 @@ final class SuggestionAccountViewModel: NSObject { } func receiveAccounts(ids: [String]) { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let users: [MastodonUser]? = { - let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) - request.returnsObjectsAsFaults = false + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + let mastodonUsers: [MastodonUser]? = { + let userFetchRequest = MastodonUser.sortedFetchRequest + userFetchRequest.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, ids: ids) + userFetchRequest.returnsObjectsAsFaults = false do { - return try context.managedObjectContext.fetch(request) + return try self.context.managedObjectContext.fetch(userFetchRequest) } catch { assertionFailure(error.localizedDescription) return nil } }() - if let accounts = users?.map(\.objectID) { - self.accounts.value = accounts + if let users = mastodonUsers { + let sortedUsers = users.sorted { (user1, user2) -> Bool in + (ids.firstIndex(of: user1.id) ?? 0) < (ids.firstIndex(of: user2.id) ?? 0) + } + accounts.value = sortedUsers.map(\.objectID) } } @@ -142,7 +150,6 @@ final class SuggestionAccountViewModel: NSObject { os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: self.delegate?.homeTimelineNeedRefresh.send() - break } } receiveValue: { _ in } From e3df692c3f86c757cd16470e5de100a2f8c8ae8a Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 19 Apr 2021 20:34:08 +0800 Subject: [PATCH 289/400] feat: report --- CoreDataStack/Entity/Status.swift | 4 + Localization/app.json | 17 +- Mastodon.xcodeproj/project.pbxproj | 44 +++ Mastodon/Coordinator/SceneCoordinator.swift | 25 +- .../Diffiable/Section/ReportSection.swift | 60 ++++ Mastodon/Generated/Assets.swift | 2 + Mastodon/Generated/Strings.swift | 22 ++ .../elevatedPrimary.colorset/Contents.json | 38 +++ .../secondary.colorset/Contents.json | 38 +++ .../Resources/en.lproj/Localizable.strings | 11 +- ...meTimelineViewController+DebugAction.swift | 34 +++ Mastodon/Scene/Report/ReportView.swift | 201 +++++++++++++ .../Scene/Report/ReportViewController.swift | 267 ++++++++++++++++++ .../Scene/Report/ReportViewModel+Data.swift | 98 +++++++ .../Report/ReportViewModel+Diffable.swift | 37 +++ .../Report/ReportViewModel+Provider.swift | 86 ++++++ Mastodon/Scene/Report/ReportViewModel.swift | 249 ++++++++++++++++ .../Report/ReportedStatusTableviewCell.swift | 176 ++++++++++++ .../Settings/SettingsViewController.swift | 2 +- .../APIService/APIService+Report.swift | 23 ++ .../MastodonSDK/API/Mastodon+API+Report.swift | 79 ++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 22 files changed, 1504 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Diffiable/Section/ReportSection.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json create mode 100644 Mastodon/Scene/Report/ReportView.swift create mode 100644 Mastodon/Scene/Report/ReportViewController.swift create mode 100644 Mastodon/Scene/Report/ReportViewModel+Data.swift create mode 100644 Mastodon/Scene/Report/ReportViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Report/ReportViewModel+Provider.swift create mode 100644 Mastodon/Scene/Report/ReportViewModel.swift create mode 100644 Mastodon/Scene/Report/ReportedStatusTableviewCell.swift create mode 100644 Mastodon/Service/APIService/APIService+Report.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index f40f78639..81ace7a5d 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -337,4 +337,8 @@ extension Status { public static func deleted() -> NSPredicate { return NSPredicate(format: "%K != nil", #keyPath(Status.deletedAt)) } + + public static func author(author: MastodonUser) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Status.author), author) + } } diff --git a/Localization/app.json b/Localization/app.json index 0aa622718..18d7a193e 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,7 +51,8 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", - "open_in_safari": "Open in Safari" + "open_in_safari": "Open in Safari", + "skip": "Skip" }, "status": { "user_reblogged": "%s reblogged", @@ -329,7 +330,7 @@ }, "favorite": { "title": "Your Favorites" - }, + }, "notification": { "title": { "Everything": "Everything", @@ -341,6 +342,7 @@ "reblog": "rebloged your post", "poll": "Your poll has ended", "mention": "mentioned you" + } }, "thread": { "back_title": "Post", @@ -388,6 +390,17 @@ "signout": "Sign Out" } } + }, + "report": { + "title": "Report %s", + "step1": "Step 1 of 2", + "step2": "Step 2 of 2", + "content1": "Are there any other posts you’d like to add to the report?", + "content2": "Is there anything the moderators should know about this report?", + "send": "Send Report", + "skipToSend": "Send without comment", + "textPlaceHolder": "|Type or paste additional comments" } } } + diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 7b147991f..67bd4878a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -133,6 +133,10 @@ 2DF75BC725D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DF75BC625D1475D00694EC8 /* ManagedObjectContextObjectsDidChange.swift */; }; 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5262616F9D300F9EE7C /* SearchViewController+Searching.swift */; }; 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; }; + 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; + 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */; }; + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; @@ -144,6 +148,11 @@ 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */; }; + 5BB04FDB262EA3070043BFF6 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportView.swift */; }; + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */; }; + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */; }; + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */; }; 5D0393902612D259007FE196 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D03938F2612D259007FE196 /* WebViewController.swift */; }; 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0393952612D266007FE196 /* WebViewModel.swift */; }; 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 5D526FE125BE9AC400460CB9 /* MastodonSDK */; }; @@ -547,6 +556,10 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; + 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Provider.swift"; sourceTree = ""; }; + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; @@ -558,6 +571,11 @@ 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; + 5BB04FDA262EA3070043BFF6 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = ""; }; + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = ""; }; + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = ""; }; 5D03938F2612D259007FE196 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; 5D0393952612D266007FE196 /* WebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewModel.swift; sourceTree = ""; }; 5DA732CB2629CEF500A92342 /* UIView+Remove.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Remove.swift"; sourceTree = ""; }; @@ -1114,6 +1132,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, ); path = Section; sourceTree = ""; @@ -1217,6 +1236,20 @@ name = Frameworks; sourceTree = ""; }; + 5B24BBD6262DB14800A9381B /* Report */ = { + isa = PBXGroup; + children = ( + 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, + 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, + 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */, + 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, + 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, + 5BB04FDA262EA3070043BFF6 /* ReportView.swift */, + 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, + ); + path = Report; + sourceTree = ""; + }; 5B90C455262599800002E742 /* Settings */ = { isa = PBXGroup; children = ( @@ -1429,6 +1462,7 @@ 2D34D9DA261494120081BFC0 /* APIService+Search.swift */, 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, + 5B24BBE1262DB19100A9381B /* APIService+Report.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, @@ -1668,6 +1702,7 @@ DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, 2D76316325C14BAC00929FB9 /* PublicTimeline */, + 5B24BBD6262DB14800A9381B /* Report */, 0F2021F5261325ED000C64BF /* HashtagTimeline */, DB9D6BEE25E4F5370051B173 /* Search */, 5B90C455262599800002E742 /* Settings */, @@ -2327,10 +2362,12 @@ 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, + 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, + 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, @@ -2439,6 +2476,7 @@ 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */, DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */, 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */, + 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */, DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */, 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, @@ -2490,6 +2528,7 @@ DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */, 2D61254D262547C200299647 /* APIService+Notification.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */, DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */, DB938EED2623F79B00E5B6C1 /* ThreadViewModel.swift in Sources */, DBC7A67C260DFADE00E57475 /* StatusPublishService.swift in Sources */, @@ -2497,6 +2536,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, + 5BB04FDB262EA3070043BFF6 /* ReportView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, @@ -2519,6 +2559,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2594,6 +2635,8 @@ DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, + 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, + 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, @@ -2604,6 +2647,7 @@ DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, + 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */, DBAE3F942616E28B004B8251 /* APIService+Follow.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..93393d542 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -65,10 +65,10 @@ extension SceneCoordinator { case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - + case settings + case report(userId: String, statusId: String?) #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -265,15 +265,28 @@ private extension SceneCoordinator { activityViewController.popoverPresentationController?.sourceView = sourceView activityViewController.popoverPresentationController?.barButtonItem = barButtonItem viewController = activityViewController + case .settings: + let _viewController = SettingsViewController() + _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + viewController = _viewController + case .report(let userId, let statusId): + guard let authenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + let _viewController = ReportViewController() + _viewController.viewModel = ReportViewModel( + context: appContext, + coordinator: self, + domain: authenticationBox.domain, + userId: userId, + statusId: statusId + ) + viewController = _viewController #if DEBUG case .publicTimeline: let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController - case .settings: - let _viewController = SettingsViewController() - _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) - viewController = _viewController #endif } diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift new file mode 100644 index 000000000..7567488c5 --- /dev/null +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -0,0 +1,60 @@ +// +// ReportSection.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import AVKit +import os.log + +enum ReportSection: Equatable, Hashable { + case main +} + +extension ReportSection { + static func tableViewDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + managedObjectContext: NSManagedObjectContext, + timestampUpdatePublisher: AnyPublisher, + reportdStatusDelegate: ReportedStatusTableViewCellDelegate + ) -> UITableViewDiffableDataSource { + UITableViewDiffableDataSource(tableView: tableView) {[ + weak dependency + ] tableView, indexPath, item -> UITableViewCell? in + guard let dependency = dependency else { return UITableViewCell() } + + switch item { + case .status(let objectID, let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell + let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value + let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" + managedObjectContext.performAndWait { + let status = managedObjectContext.object(with: objectID) as! Status + StatusSection.configure( + cell: cell, + dependency: dependency, + readableLayoutFrame: tableView.readableContentGuide.layoutFrame, + timestampUpdatePublisher: timestampUpdatePublisher, + status: status, + requestUserID: requestUserID, + statusItemAttribute: attribute + ) + } + + let isSelected = reportdStatusDelegate.reportedStatus(cell: cell, isSelected: indexPath) + cell.setupSelected(isSelected) + return cell + default: + return nil + } + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ce9e33e2b..aba5e4f6b 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -41,9 +41,11 @@ internal enum Asset { internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") + internal static let elevatedPrimary = ColorAsset(name: "Colors/Background/elevatedPrimary") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") + internal static let secondary = ColorAsset(name: "Colors/Background/secondary") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b7bd3d0a8..1fb5c5f59 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -96,6 +96,8 @@ internal enum L10n { internal static let signIn = L10n.tr("Localizable", "Common.Controls.Actions.SignIn") /// Sign Up internal static let signUp = L10n.tr("Localizable", "Common.Controls.Actions.SignUp") + /// Skip + internal static let skip = L10n.tr("Localizable", "Common.Controls.Actions.Skip") /// Take photo internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") /// Try Again @@ -523,6 +525,26 @@ internal enum L10n { } } } + internal enum Report { + /// Are there any other posts you’d like to add to the report? + internal static let content1 = L10n.tr("Localizable", "Scene.Report.Content1") + /// Is there anything the moderators should know about this report? + internal static let content2 = L10n.tr("Localizable", "Scene.Report.Content2") + /// Send Report + internal static let send = L10n.tr("Localizable", "Scene.Report.Send") + /// Send without comment + internal static let skiptosend = L10n.tr("Localizable", "Scene.Report.Skiptosend") + /// Step 1 of 2 + internal static let step1 = L10n.tr("Localizable", "Scene.Report.Step1") + /// Step 2 of 2 + internal static let step2 = L10n.tr("Localizable", "Scene.Report.Step2") + /// |Type or paste additional comments + internal static let textplaceholder = L10n.tr("Localizable", "Scene.Report.Textplaceholder") + /// Report %@ + internal static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Scene.Report.Title", String(describing: p1)) + } + } internal enum Search { internal enum Recommend { /// See All diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json new file mode 100644 index 000000000..82edd034b --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json new file mode 100644 index 000000000..5e7067405 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "254", + "green" : "255", + "red" : "254" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "46", + "green" : "44", + "red" : "44" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 253b65d95..b413d887c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -31,6 +31,7 @@ Please check your internet connection."; "Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; +"Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Firendship.Block" = "Block"; @@ -168,6 +169,14 @@ tap the link to confirm your account."; "Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; "Scene.Register.Input.Username.Placeholder" = "username"; "Scene.Register.Title" = "Tell us about you."; +"Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; +"Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; +"Scene.Report.Send" = "Send Report"; +"Scene.Report.Skiptosend" = "Send without comment"; +"Scene.Report.Step1" = "Step 1 of 2"; +"Scene.Report.Step2" = "Step 2 of 2"; +"Scene.Report.Textplaceholder" = "|Type or paste additional comments"; +"Scene.Report.Title" = "Report %@"; "Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; @@ -227,4 +236,4 @@ any server."; "Scene.Thread.Reblog.Single" = "%@ reblog"; "Scene.Thread.Title" = "Post from %@"; "Scene.Welcome.Slogan" = "Social networking -back in your hands."; +back in your hands."; \ No newline at end of file diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..459652008 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -41,6 +41,10 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showSettings(action) }, + UIAction(title: "Report", image: UIImage(systemName: "exclamationmark.bubble"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showReportAction(action) + }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -330,5 +334,35 @@ extension HomeTimelineViewController { @objc private func showSettings(_ sender: UIAction) { coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) } + + @objc private func showReportAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + alertController.addTextField() + guard let accountTextField = alertController.textFields?.first else { return } + guard let statusTextField = alertController.textFields?.last else { return } + accountTextField.placeholder = "User ID" + statusTextField.placeholder = "Status ID" + accountTextField.text = "212477" + statusTextField.text = "106103767536113615" + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self] _ in + guard let self = self else { return } + + guard let userId = accountTextField.text else { return } + guard let statusId = statusTextField.text else { return } + + // itodo: delete them + // 31803 + // 106093402888557459 + self.coordinator.present( + scene: .report(userId: userId, statusId: statusId), + from: self, transition: .modal(animated: true, completion: nil)) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + } #endif diff --git a/Mastodon/Scene/Report/ReportView.swift b/Mastodon/Scene/Report/ReportView.swift new file mode 100644 index 000000000..9166259fe --- /dev/null +++ b/Mastodon/Scene/Report/ReportView.swift @@ -0,0 +1,201 @@ +// +// ReportView.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import UIKit + +struct ReportView { + static var horizontalMargin: CGFloat { return 12 } + static var verticalMargin: CGFloat { return 22 } + static var buttonHeight: CGFloat { return 46 } + static var skipBottomMargin: CGFloat { return 8 } + static var continuTopMargin: CGFloat { return 22 } +} + +final class ReportViewHeader: UIView { + enum Step: Int { + case one + case two + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFont.preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 0 + return label + }() + + lazy var contentLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFont.preferredFont(forTextStyle: .title3) + label.numberOfLines = 0 + return label + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .leading + view.spacing = 2 + return view + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + titleLabel.text = L10n.Scene.Report.step1 + contentLabel.text = L10n.Scene.Report.content1 + case .two: + titleLabel.text = L10n.Scene.Report.step2 + contentLabel.text = L10n.Scene.Report.content2 + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + stackview.addArrangedSubview(titleLabel) + stackview.addArrangedSubview(contentLabel) + addSubview(stackview) + + stackview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackview.safeAreaLayoutGuide.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.verticalMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.bottomAnchor, + constant: -1 * ReportView.verticalMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class ReportViewFooter: UIView { + enum Step: Int { + case one + case two + } + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = 8 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var nextStepButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + lazy var skipButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = Asset.Colors.brandBlue.color + button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + case .two: + nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) + skipButton.setTitle(L10n.Scene.Report.skiptosend, for: .normal) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + + stackview.addArrangedSubview(nextStepButton) + stackview.addArrangedSubview(skipButton) + addSubview(stackview) + + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.continuTopMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.safeAreaLayoutGuide.bottomAnchor, + constant: -1 * ReportView.skipBottomMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ), + nextStepButton.heightAnchor.constraint( + equalToConstant: ReportView.buttonHeight + ), + skipButton.heightAnchor.constraint( + equalTo: nextStepButton.heightAnchor + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview { () -> UIView in + let view = ReportViewHeader() + view.step = .one + view.contentLabel.preferredMaxLayoutWidth = 335 + return view + } + .previewLayout(.fixed(width: 375, height: 110)) + + UIViewPreview(width: 375) { () -> UIView in + return ReportViewFooter(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) + } + .previewLayout(.fixed(width: 375, height: 164)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift new file mode 100644 index 000000000..7d46ae6b9 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -0,0 +1,267 @@ +// +// ReportViewController.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import AVKit +import Combine +import CoreData +import CoreDataStack +import os.log +import UIKit +import TwitterTextEditor + +class ReportViewController: UIViewController, NeedsDependency { + static let kAnimationDuration: TimeInterval = 0.33 + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: ReportViewModel! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + let didToggleSelected = PassthroughSubject() + let comment = CurrentValueSubject(nil) + let step1Continue = PassthroughSubject() + let step1Skip = PassthroughSubject() + let step2Continue = PassthroughSubject() + let step2Skip = PassthroughSubject() + let cancel = PassthroughSubject() + + // MAKK: - UI + lazy var header: ReportViewHeader = { + let view = ReportViewHeader() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var footer: ReportViewFooter = { + let view = ReportViewFooter() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var contentView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultLow, for: .vertical) + view.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + return view + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.distribution = .fill + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var tableView: UITableView = { + let tableView = ControlContainableTableView() + tableView.register(ReportedStatusTableViewCell.self, forCellReuseIdentifier: String(describing: ReportedStatusTableViewCell.self)) + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + return tableView + }() + + lazy var textView: UITextView = { + let textView = UITextView() + textView.font = .preferredFont(forTextStyle: .body) + textView.isScrollEnabled = false + textView.placeholder = L10n.Scene.Report.textplaceholder + textView.backgroundColor = .clear + textView.delegate = self + return textView + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + bindViewModel() + bindActions() + } + + // MAKR: - Private methods + private func setupView() { + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color + setupNavigation() + + stackview.addArrangedSubview(header) + stackview.addArrangedSubview(contentView) + stackview.addArrangedSubview(footer) + + contentView.addSubview(tableView) + + view.addSubview(stackview) + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stackview.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackview.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackview.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: contentView.topAnchor), + tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + ]) + + header.step = .one + } + + private func bindActions() { + footer.nextStepButton.addTarget(self, action: #selector(continueButtonDidClick), for: .touchUpInside) + footer.skipButton.addTarget(self, action: #selector(skipButtonDidClick), for: .touchUpInside) + } + + private func bindViewModel() { + let input = ReportViewModel.Input( + didToggleSelected: didToggleSelected.eraseToAnyPublisher(), + comment: comment.eraseToAnyPublisher(), + step1Continue: step1Continue.eraseToAnyPublisher(), + step1Skip: step1Skip.eraseToAnyPublisher(), + step2Continue: step2Continue.eraseToAnyPublisher(), + step2Skip: step2Skip.eraseToAnyPublisher(), + cancel: cancel.eraseToAnyPublisher(), + tableView: tableView + ) + let output = viewModel.transform(input: input) + output?.currentStep + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (step) in + guard step == .two else { return } + guard let self = self else { return } + + self.header.step = .two + self.footer.step = .two + self.switchToStep2Content() + }) + .store(in: &disposeBag) + + output?.continueEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .one else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.sendEnableSubject + .receive(on: DispatchQueue.main) + .filter { [weak self] _ in + guard let step = self?.viewModel.currentStep.value, step == .two else { return false } + return true + } + .assign(to: \.nextStepButton.isEnabled, on: footer) + .store(in: &disposeBag) + + output?.reportSuccess + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] (_) in + self?.dismiss(animated: true, completion: nil) + }) + .store(in: &disposeBag) + } + + private func setupNavigation() { + navigationItem.rightBarButtonItem + = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.cancel, + target: self, + action: #selector(doneButtonDidClick)) + navigationItem.rightBarButtonItem?.tintColor = Asset.Colors.Label.highlight.color + + // fetch old mastodon user + let beReportedUser: MastodonUser? = { + guard let domain = context.authenticationService.activeMastodonAuthenticationBox.value?.domain else { + return nil + } + let request = MastodonUser.sortedFetchRequest + request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.userId) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + + navigationItem.title = L10n.Scene.Report.title( + "\(beReportedUser?.displayName ?? "@\(beReportedUser?.acct ?? "")")" + ) + } + + private func switchToStep2Content() { + self.contentView.addSubview(self.textView) + self.textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.textView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.textView.leadingAnchor.constraint( + equalTo: self.contentView.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.textView.trailingAnchor.constraint( + equalTo: self.contentView.safeAreaLayoutGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ), + ]) + self.textView.layoutIfNeeded() + + UIView.transition( + with: contentView, + duration: ReportViewController.kAnimationDuration, + options: UIView.AnimationOptions.transitionCrossDissolve) { + [weak self] in + guard let self = self else { return } + + self.contentView.addSubview(self.textView) + self.tableView.isHidden = true + } completion: { (_) in + } + } + + // Mark: - Actions + @objc func doneButtonDidClick() { + dismiss(animated: true, completion: nil) + } + + @objc func continueButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Continue.send() + } else { + step2Continue.send() + } + } + + @objc func skipButtonDidClick() { + if viewModel.currentStep.value == .one { + step1Skip.send() + } else { + step2Skip.send() + } + } +} + +extension ReportViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } + + didToggleSelected.send(item) + } +} + +extension ReportViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + self.comment.send(textView.text) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift new file mode 100644 index 000000000..4df2ccb20 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -0,0 +1,98 @@ +// +// ReportViewModel+Data.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +extension ReportViewModel { + func requestRecentStatus( + domain: String, + accountId: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) { + context.apiService.userTimeline( + domain: domain, + accountID: accountId, + authorizationBox: authorizationBox + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + guard let self = self else { return } + guard let reportStatusId = self.statusId else { return } + var statusIDs = self.statusFetchedResultsController.statusIDs.value + guard statusIDs.contains(reportStatusId) else { return } + + statusIDs.append(reportStatusId) + self.statusFetchedResultsController.statusIDs.value = statusIDs + case .finished: + break + } + } receiveValue: { [weak self] response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + + var statusIDs = response.value.map { $0.id } + if let reportStatusId = self.statusId, !statusIDs.contains(reportStatusId) { + statusIDs.append(reportStatusId) + } + + self.statusFetchedResultsController.statusIDs.value = statusIDs + } + .store(in: &disposeBag) + } + + func fetchStatus() { + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + statusFetchedResultsController.objectIDs.eraseToAnyPublisher() + .receive(on: DispatchQueue.main) + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] objectIDs in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var items: [Item] = [] + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + defer { + // not animate when empty items fix loader first appear layout issue + diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + } + + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + let oldSnapshot = diffableDataSource.snapshot() + for item in oldSnapshot.itemIdentifiers { + guard case let .status(objectID, attribute) = item else { continue } + oldSnapshotAttributeDict[objectID] = attribute + } + + for objectID in objectIDs { + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() + let item = Item.status(objectID: objectID, attribute: attribute) + items.append(item) + + guard let status = managedObjectContext.object(with: objectID) as? Status else { + continue + } + if status.id == self.statusId { + self.selectedItems.append(item) + self.continueEnableSubject.send(true) + } + } + snapshot.appendItems(items, toSection: .main) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift new file mode 100644 index 000000000..38f2edb19 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -0,0 +1,37 @@ +// +// ReportViewModel+Diffable.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack + +extension ReportViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + dependency: NeedsDependency, + reportdStatusDelegate: ReportedStatusTableViewCellDelegate + ) { + let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + diffableDataSource = ReportSection.tableViewDiffableDataSource( + for: tableView, + dependency: dependency, + managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, + timestampUpdatePublisher: timestampUpdatePublisher, + reportdStatusDelegate: reportdStatusDelegate + ) + + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource?.apply(snapshot) + } +} diff --git a/Mastodon/Scene/Report/ReportViewModel+Provider.swift b/Mastodon/Scene/Report/ReportViewModel+Provider.swift new file mode 100644 index 000000000..052fc2ba1 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel+Provider.swift @@ -0,0 +1,86 @@ +//// +//// ReportViewModel+Provider.swift +//// Mastodon +//// +//// Created by ihugo on 2021/4/19. +//// +// +//import Combine +//import CoreData +//import CoreDataStack +//import Foundation +//import MastodonSDK +//import UIKit +//import os.log +// +//extension ReportViewController: StatusProvider { +// func status() -> Future { +// return Future { promise in promise(.success(nil)) } +// } +// +// func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { +// return Future { promise in +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// promise(.success(nil)) +// return +// } +// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), +// let item = diffableDataSource.itemIdentifier(for: indexPath) else { +// promise(.success(nil)) +// return +// } +// +// switch item { +// case .status(let objectID, _): +// let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext +// managedObjectContext.perform { +// let status = managedObjectContext.object(with: objectID) as? Status +// promise(.success(status)) +// } +// default: +// promise(.success(nil)) +// } +// } +// } +// +// func status(for cell: UICollectionViewCell) -> Future { +// return Future { promise in promise(.success(nil)) } +// } +// +// var managedObjectContext: NSManagedObjectContext { +// return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext +// } +// +// var tableViewDiffableDataSource: UITableViewDiffableDataSource? { +// return viewModel.diffableDataSource +// } +// +// func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// return nil +// } +// +// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), +// let item = diffableDataSource.itemIdentifier(for: indexPath) else { +// return nil +// } +// +// return item +// } +// +// func items(indexPaths: [IndexPath]) -> [Item] { +// guard let diffableDataSource = self.viewModel.diffableDataSource else { +// assertionFailure() +// return [] +// } +// +// var items: [Item] = [] +// for indexPath in indexPaths { +// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } +// items.append(item) +// } +// return items +// } +//} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift new file mode 100644 index 000000000..94404af10 --- /dev/null +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -0,0 +1,249 @@ +// +// ReportViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +class ReportViewModel: NSObject, NeedsDependency { + typealias FileReportQuery = Mastodon.API.Reports.FileReportQuery + + enum Step: Int { + case one + case two + } + + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } + var userId: String + var statusId: String? + var selectedItems = [Item]() + var comment: String? + + internal var reportQuery: FileReportQuery + internal var disposeBag = Set() + internal let currentStep = CurrentValueSubject(.one) + internal let statusFetchedResultsController: StatusFetchedResultsController + internal var diffableDataSource: UITableViewDiffableDataSource? + internal let continueEnableSubject = CurrentValueSubject(false) + internal let sendEnableSubject = CurrentValueSubject(false) + internal let reportSuccess = PassthroughSubject() + + struct Input { + let didToggleSelected: AnyPublisher + let comment: AnyPublisher + let step1Continue: AnyPublisher + let step1Skip: AnyPublisher + let step2Continue: AnyPublisher + let step2Skip: AnyPublisher + let cancel: AnyPublisher + let tableView: UITableView + } + + struct Output { + let currentStep: AnyPublisher + let continueEnableSubject: AnyPublisher + let sendEnableSubject: AnyPublisher + let reportSuccess: AnyPublisher + } + + init(context: AppContext, + coordinator: SceneCoordinator, + domain: String, + userId: String, + statusId: String? + ) { + self.context = context + self.coordinator = coordinator + self.userId = userId + self.statusId = statusId + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: domain, + additionalTweetPredicate: Status.notDeleted() + ) + + self.reportQuery = FileReportQuery( + accountId: userId, + statusIds: nil, + comment: nil, + forward: nil + ) + super.init() + } + + func transform(input: Input?) -> Output? { + guard let input = input else { return nil } + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + let domain = activeMastodonAuthenticationBox.domain + + setupDiffableDataSource( + for: input.tableView, + dependency: self, + reportdStatusDelegate: self + ) + + // data binding + bindData(input: input) + + // step1 and step2 binding + bindForStep1(input: input) + bindForStep2( + input: input, + domain: domain, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + + requestRecentStatus( + domain: domain, + accountId: self.userId, + authorizationBox: activeMastodonAuthenticationBox + ) + + fetchStatus() + + return Output( + currentStep: currentStep.eraseToAnyPublisher(), + continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), + sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), + reportSuccess: reportSuccess.eraseToAnyPublisher() + ) + } + + // MARK: - Private methods + func bindData(input: Input) { + input.didToggleSelected.sink { [weak self] (item) in + guard let self = self else { return } + guard case let .status(objectID, attribute) = item else { return } + guard var snapshot = self.diffableDataSource?.snapshot() else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + guard let status = managedObjectContext.object(with: objectID) as? Status else { + return + } + + var items = [Item]() + if let index = self.selectedItems.firstIndex(of: item) { + self.selectedItems.remove(at: index) + items.append(.status(objectID: objectID, attribute: attribute)) + + if let index = self.reportQuery.statusIds?.firstIndex(of: status.id) { + self.reportQuery.statusIds?.remove(at: index) + } + } else { + self.selectedItems.append(item) + items.append(.status(objectID: objectID, attribute: attribute)) + self.reportQuery.statusIds?.append(status.id) + } + + snapshot.reloadItems([item]) + self.diffableDataSource?.apply(snapshot, animatingDifferences: false) + + let continueEnable = self.selectedItems.count > 0 + self.continueEnableSubject.send(continueEnable) + } + .store(in: &disposeBag) + + input.comment.assign( + to: \.comment, + on: self + ) + .store(in: &disposeBag) + input.comment.sink { [weak self] (comment) in + guard let self = self else { return } + let sendEnable = (comment?.length ?? 0) > 0 + self.sendEnableSubject.send(sendEnable) + } + .store(in: &disposeBag) + } + + func bindForStep1(input: Input) { + let skip = input.step1Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.selectedItems.removeAll() + return value + } + + Publishers.Merge(skip, input.step1Continue) + .sink { [weak self] _ in + self?.currentStep.value = .two + self?.sendEnableSubject.send(false) + } + .store(in: &disposeBag) + } + + func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) { + let skip = input.step2Skip.map { [weak self] value -> Void in + guard let self = self else { return value } + self.comment = nil + return value + } + + Publishers.Merge(skip, input.step2Continue) + .sink { [weak self] _ in + guard let self = self else { return } + let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext + + self.reportQuery.comment = self.comment + + var selectedStatusIds = [String]() + self.selectedItems.forEach { (item) in + guard case .status(let objectId, _) = item else { + return + } + guard let status = managedObjectContext.object(with: objectId) as? Status else { + return + } + selectedStatusIds.append(status.id) + } + self.reportQuery.statusIds = selectedStatusIds + + self.context.apiService.report( + domain: domain, + query: self.reportQuery, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .sink { [weak self](data) in + switch data { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + self?.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + case .finished: + self?.reportSuccess.send() + } + + } receiveValue: { (data) in + } + .store(in: &self.disposeBag) + } + .store(in: &disposeBag) + } +} + +extension ReportViewModel: ReportedStatusTableViewCellDelegate { + func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool { + guard let item = diffableDataSource?.itemIdentifier(for: indexPath) else { + return false + } + + return selectedItems.contains(item) + } +} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift new file mode 100644 index 000000000..8c7bd221c --- /dev/null +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -0,0 +1,176 @@ +// +// ReportedStatusTableViewCell.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import os.log +import UIKit +import AVKit +import Combine +import CoreData +import CoreDataStack +import ActiveLabel + +protocol ReportedStatusTableViewCellDelegate: class { + func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool +} + +final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { + + static let bottomPaddingHeight: CGFloat = 10 + + weak var delegate: ReportedStatusTableViewCellDelegate? + var disposeBag = Set() + var pollCountdownSubscription: AnyCancellable? + var observations = Set() + var checked: Bool = false + + let statusView = StatusView() + let separatorLine = UIView.separatorLine + + let checkbox: UIImageView = { + let imageView = UIImageView() + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .body) + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + + override func prepareForReuse() { + super.prepareForReuse() + checked = false + statusView.isStatusTextSensitive = false + statusView.cleanUpContentWarning() + statusView.pollTableView.dataSource = nil + statusView.playerContainerView.reset() + statusView.playerContainerView.isHidden = true + disposeBag.removeAll() + observations.removeAll() + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + + override func layoutSubviews() { + super.layoutSubviews() + DispatchQueue.main.async { + self.statusView.drawContentWarningImageView() + } + } + + override func setHighlighted(_ highlighted: Bool, animated: Bool) { + super.setHighlighted(highlighted, animated: animated) + if highlighted { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + checkbox.tintColor = Asset.Colors.Label.highlight.color + } else if !checked { + checkbox.image = UIImage(systemName: "circle") + checkbox.tintColor = Asset.Colors.Label.secondary.color + } + } +} + +extension ReportedStatusTableViewCell { + + private func _init() { + backgroundColor = Asset.Colors.Background.systemBackground.color + statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color + + checkbox.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.widthAnchor.constraint(equalToConstant: 23), + checkbox.heightAnchor.constraint(equalToConstant: 22), + checkbox.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor, constant: 12), + checkbox.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + + statusView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(statusView) + NSLayoutConstraint.activate([ + statusView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + statusView.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 20), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: statusView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: statusView.bottomAnchor, constant: 20), + ]) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + + selectionStyle = .none + statusView.actionToolbarContainer.isHidden = true + statusView.isUserInteractionEnabled = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() + } + + func setupSelected(_ selected: Bool) { + checked = selected + if selected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + checkbox.tintColor = Asset.Colors.Label.secondary.color + } +} + +extension ReportedStatusTableViewCell { + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } +} diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4615f92ab..156321d89 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -148,7 +148,7 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupView() { - view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color setupNavigation() setupTableView() diff --git a/Mastodon/Service/APIService/APIService+Report.swift b/Mastodon/Service/APIService/APIService+Report.swift new file mode 100644 index 000000000..3c170c625 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Report.swift @@ -0,0 +1,23 @@ +// +// APIService+Report.swift +// Mastodon +// +// Created by ihugo on 2021/4/19. +// + +import Foundation +import MastodonSDK +import Combine + +extension APIService { + + func report( + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Reports.fileReport(session: session, domain: domain, query: query, authorization: authorization) + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift new file mode 100644 index 000000000..eac7f64f7 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -0,0 +1,79 @@ +// +// File.swift +// +// +// Created by ihugo on 2021/4/19. +// + +import Combine +import Foundation + +extension Mastodon.API.Reports { + static func reportsEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("reports") + } + + /// File a report + /// + /// Version history: + /// 1.1 - added + /// 2.3.0 - add forward parameter + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/search/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: fileReportQuery query + /// - authorization: User token + /// - Returns: `AnyPublisher` contains status indicate if report sucessfully. + public static func fileReport( + session: URLSession, + domain: String, + query: Mastodon.API.Reports.FileReportQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: reportsEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + if let response = response as? HTTPURLResponse { + return Mastodon.Response.Content( + value: response.statusCode == 200, + response: response + ) + } + return Mastodon.Response.Content(value: false, response: response) + } + .eraseToAnyPublisher() + } +} + + +public extension Mastodon.API.Reports { + class FileReportQuery: Codable, PostQuery { + public let accountId: String + public var statusIds: [String]? + public var comment: String? + public let forward: Bool? + + enum CodingKeys: String, CodingKey { + case accountId = "account_id" + case statusIds = "status_ids" + case comment + case forward + } + + public init(accountId: String, + statusIds: [String]?, + comment: String?, + forward: Bool?) { + self.accountId = accountId + self.statusIds = statusIds + self.comment = comment + self.forward = forward + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..c85c2cd74 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -116,6 +116,7 @@ extension Mastodon.API { public enum Suggestions { } public enum Notifications { } public enum Subscriptions { } + public enum Reports { } } extension Mastodon.API { From d1e3039e386c5791b0f28feb344e78a7c2474710 Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 22 Apr 2021 13:08:53 +0800 Subject: [PATCH 290/400] fix: compile issue --- Mastodon/Scene/Report/ReportedStatusTableviewCell.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 8c7bd221c..a25c8b507 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -48,10 +48,11 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() checked = false - statusView.isStatusTextSensitive = false - statusView.cleanUpContentWarning() + statusView.updateContentWarningDisplay(isHidden: true, animated: false) + statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil statusView.playerContainerView.reset() + statusView.playerContainerView.contentWarningOverlayView.isUserInteractionEnabled = true statusView.playerContainerView.isHidden = true disposeBag.removeAll() observations.removeAll() @@ -90,7 +91,6 @@ extension ReportedStatusTableViewCell { private func _init() { backgroundColor = Asset.Colors.Background.systemBackground.color - statusView.contentWarningBlurContentImageView.backgroundColor = Asset.Colors.Background.systemBackground.color checkbox.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(checkbox) From d4f4a3e08671c2cd79b4b2fbfb2d022388caebd7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 14:36:29 +0800 Subject: [PATCH 291/400] fix: search scene UI update --- ...earchRecommendTagsCollectionViewCell.swift | 2 +- .../SearchViewController+Recommend.swift | 8 ++-- .../Scene/Search/SearchViewController.swift | 45 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index abcd9d08d..002929510 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -83,7 +83,6 @@ extension SearchRecommendTagsCollectionViewCell { let containerStackView = UIStackView() containerStackView.axis = .vertical containerStackView.distribution = .fill - containerStackView.spacing = 6 containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16) containerStackView.isLayoutMarginsRelativeArrangement = true containerStackView.translatesAutoresizingMaskIntoConstraints = false @@ -113,6 +112,7 @@ extension SearchRecommendTagsCollectionViewCell { peopleLabel.translatesAutoresizingMaskIntoConstraints = false peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) containerStackView.addArrangedSubview(peopleLabel) + containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView) } func config(with tag: Mastodon.Entity.Tag) { diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index b5ff0e54b..3425ac193 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -26,7 +26,7 @@ extension SearchViewController { hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(hashtagCollectionView) NSLayoutConstraint.activate([ - hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight)) ]) } @@ -43,7 +43,7 @@ extension SearchViewController { accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(accountsCollectionView) NSLayoutConstraint.activate([ - accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) + accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight)) ]) } @@ -91,9 +91,9 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { if collectionView == hashtagCollectionView { - return CGSize(width: 228, height: 130) + return CGSize(width: 228, height: SearchViewController.hashtagCardHeight) } else { - return CGSize(width: 257, height: 202) + return CGSize(width: 257, height: SearchViewController.accountCardHeight) } } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 357f142c8..3731f118b 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -11,6 +11,26 @@ import MastodonSDK import UIKit final class SearchViewController: UIViewController, NeedsDependency { + + public static var hashtagCardHeight: CGFloat { + get { + if UIScreen.main.bounds.size.height > 736 { + return 186 + } + return 130 + } + } + + public static var hashtagPeopleTalkingLabelTop: CGFloat { + get { + if UIScreen.main.bounds.size.height > 736 { + return 18 + } + return 6 + } + } + public static let accountCardHeight = 202 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -49,9 +69,6 @@ final class SearchViewController: UIViewController, NeedsDependency { let stackView = UIStackView() stackView.axis = .vertical stackView.distribution = .fill - stackView.spacing = 0 - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0) - stackView.isLayoutMarginsRelativeArrangement = true return stackView }() @@ -130,6 +147,8 @@ extension SearchViewController { setupSearchingTableView() setupDataSource() setupSearchHeader() + view.bringSubviewToFront(searchBar) + view.bringSubviewToFront(statusBar) } func setupSearchBar() { @@ -148,23 +167,27 @@ extension SearchViewController { statusBar.topAnchor.constraint(equalTo: view.topAnchor), statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3), + statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3), ]) } func setupScrollView() { scrollView.translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + + // scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), ]) - - stackView.translatesAutoresizingMaskIntoConstraints = false + + // stackview scrollView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), From f84fb723df3ca3c2eea695f0dcc27144639aae31 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 14:36:29 +0800 Subject: [PATCH 292/400] fix: search scene UI update --- ...earchRecommendTagsCollectionViewCell.swift | 2 +- .../SearchViewController+Recommend.swift | 8 ++-- .../Scene/Search/SearchViewController.swift | 45 ++++++++++++++----- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift index abcd9d08d..002929510 100644 --- a/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift +++ b/Mastodon/Scene/Search/CollectionViewCell/SearchRecommendTagsCollectionViewCell.swift @@ -83,7 +83,6 @@ extension SearchRecommendTagsCollectionViewCell { let containerStackView = UIStackView() containerStackView.axis = .vertical containerStackView.distribution = .fill - containerStackView.spacing = 6 containerStackView.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16) containerStackView.isLayoutMarginsRelativeArrangement = true containerStackView.translatesAutoresizingMaskIntoConstraints = false @@ -113,6 +112,7 @@ extension SearchRecommendTagsCollectionViewCell { peopleLabel.translatesAutoresizingMaskIntoConstraints = false peopleLabel.setContentHuggingPriority(.defaultLow - 1, for: .vertical) containerStackView.addArrangedSubview(peopleLabel) + containerStackView.setCustomSpacing(SearchViewController.hashtagPeopleTalkingLabelTop, after: horizontalStackView) } func config(with tag: Mastodon.Entity.Tag) { diff --git a/Mastodon/Scene/Search/SearchViewController+Recommend.swift b/Mastodon/Scene/Search/SearchViewController+Recommend.swift index f394f09f1..7d0b0eca4 100644 --- a/Mastodon/Scene/Search/SearchViewController+Recommend.swift +++ b/Mastodon/Scene/Search/SearchViewController+Recommend.swift @@ -26,7 +26,7 @@ extension SearchViewController { hashtagCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(hashtagCollectionView) NSLayoutConstraint.activate([ - hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 130) + hashtagCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.hashtagCardHeight)) ]) } @@ -43,7 +43,7 @@ extension SearchViewController { accountsCollectionView.translatesAutoresizingMaskIntoConstraints = false stackView.addArrangedSubview(accountsCollectionView) NSLayoutConstraint.activate([ - accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 202) + accountsCollectionView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: CGFloat(SearchViewController.accountCardHeight)) ]) } @@ -91,9 +91,9 @@ extension SearchViewController: UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { if collectionView == hashtagCollectionView { - return CGSize(width: 228, height: 130) + return CGSize(width: 228, height: SearchViewController.hashtagCardHeight) } else { - return CGSize(width: 257, height: 202) + return CGSize(width: 257, height: SearchViewController.accountCardHeight) } } } diff --git a/Mastodon/Scene/Search/SearchViewController.swift b/Mastodon/Scene/Search/SearchViewController.swift index 770fb1da7..e11a0e4c2 100644 --- a/Mastodon/Scene/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/SearchViewController.swift @@ -11,6 +11,26 @@ import MastodonSDK import UIKit final class SearchViewController: UIViewController, NeedsDependency { + + public static var hashtagCardHeight: CGFloat { + get { + if UIScreen.main.bounds.size.height > 736 { + return 186 + } + return 130 + } + } + + public static var hashtagPeopleTalkingLabelTop: CGFloat { + get { + if UIScreen.main.bounds.size.height > 736 { + return 18 + } + return 6 + } + } + public static let accountCardHeight = 202 + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -49,9 +69,6 @@ final class SearchViewController: UIViewController, NeedsDependency { let stackView = UIStackView() stackView.axis = .vertical stackView.distribution = .fill - stackView.spacing = 0 - stackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 68, right: 0) - stackView.isLayoutMarginsRelativeArrangement = true return stackView }() @@ -130,6 +147,8 @@ extension SearchViewController { setupSearchingTableView() setupDataSource() setupSearchHeader() + view.bringSubviewToFront(searchBar) + view.bringSubviewToFront(statusBar) } func setupSearchBar() { @@ -148,23 +167,27 @@ extension SearchViewController { statusBar.topAnchor.constraint(equalTo: view.topAnchor), statusBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), statusBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), - statusBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 3), + statusBar.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 3), ]) } func setupScrollView() { scrollView.translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + + // scrollView view.addSubview(scrollView) NSLayoutConstraint.activate([ - scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: searchBar.bottomAnchor), - scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), - scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), - scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), - scrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: searchBar.frame.height), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), ]) - - stackView.translatesAutoresizingMaskIntoConstraints = false + + // stackview scrollView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), From 326aea36cdd2b894c996ceb2caebadc8be1b18b4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 15:45:32 +0800 Subject: [PATCH 293/400] fix: The empty view should not display when the user just sign-in the first time --- .../Section/RecommendAccountSection.swift | 2 +- .../HomeTimelineViewController.swift | 22 ++++++++++--------- ...omeTimelineViewModel+LoadLatestState.swift | 1 + .../HomeTimeline/HomeTimelineViewModel.swift | 1 + 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 1732be29f..92def9156 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -36,7 +36,7 @@ extension RecommendAccountSection { viewModel: SuggestionAccountViewModel, delegate: SuggestionAccountTableViewCellDelegate ) -> UITableViewDiffableDataSource { - UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel,weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in + UITableViewDiffableDataSource(tableView: tableView) { [weak viewModel, weak delegate] (tableView, indexPath, objectID) -> UITableViewCell? in guard let viewModel = viewModel else { return nil } let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell let user = managedObjectContext.object(with: objectID) as! MastodonUser diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 2734b33e1..bd559eed5 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -151,16 +151,7 @@ extension HomeTimelineViewController { UIView.animate(withDuration: 0.5) { [weak self] in guard let self = self else { return } self.refreshControl.endRefreshing() - } completion: { [weak self] _ in - guard let self = self else { return } - if (self.viewModel.fetchedResultsController.fetchedObjects ?? []).isEmpty { - self.showEmptyView() - } else { - self.emptyView.removeFromSuperview() - } - } - } else { - self.emptyView.removeFromSuperview() + } completion: { _ in } } } .store(in: &disposeBag) @@ -191,6 +182,17 @@ extension HomeTimelineViewController { self.publishProgressView.setProgress(progress, animated: true) } .store(in: &disposeBag) + + viewModel.timelineIsEmpty + .receive(on: DispatchQueue.main) + .sink { [weak self] isEmpty in + if isEmpty { + self?.showEmptyView() + } else { + self?.emptyView.removeFromSuperview() + } + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift index 640d9df3b..425eb9aa0 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadLatestState.swift @@ -107,6 +107,7 @@ extension HomeTimelineViewModel.LoadLatestState { viewModel.homeTimelineNavigationBarTitleViewModel.newPostsIncoming() } } + viewModel.timelineIsEmpty.value = latestStatusIDs.isEmpty && statuses.isEmpty } .store(in: &viewModel.disposeBag) } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index a81056d0f..26ef485ee 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -34,6 +34,7 @@ final class HomeTimelineViewModel: NSObject { weak var tableView: UITableView? weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate? + let timelineIsEmpty = CurrentValueSubject(false) let homeTimelineNeedRefresh = PassthroughSubject() // output // top loader From 64b4247706484db6bf7c6757423aeecc217a66ea Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 15:52:10 +0800 Subject: [PATCH 294/400] chore: add a debug menu entry for testing EmptyView --- .../HomeTimelineViewController+DebugAction.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..e338aa09f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -25,6 +25,14 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showWelcomeAction(action) }, + UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in + guard let self = self else { return } + if self.emptyView.superview != nil { + self.emptyView.removeFromSuperview() + } else { + self.showEmptyView() + } + }, UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in guard let self = self else { return } self.showPublicTimelineAction(action) From e664722b13ac7f6a61ba03ff75b5fd33b21101da Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 19:58:42 +0800 Subject: [PATCH 295/400] chore: update UI/UX of suggestion account --- Mastodon.xcodeproj/project.pbxproj | 20 +++ .../Diffiable/Item/SelectedAccountItem.swift | 38 +++++ .../Section/SelectedAccountSection.swift | 35 +++++ Mastodon/Generated/Assets.swift | 1 + .../Label/tertiary.colorset/Contents.json | 20 +++ .../HomeTimeline/HomeTimelineViewModel.swift | 1 - .../ProfileRelationshipActionButton.swift | 18 +-- .../SuggestionAccountCollectionViewCell.swift | 60 ++++++++ .../SuggestionAccountViewController.swift | 138 ++++++++++++------ .../SuggestionAccountViewModel.swift | 64 +++++--- .../SuggestionAccountTableViewCell.swift | 57 +++++++- 11 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 Mastodon/Diffiable/Item/SelectedAccountItem.swift create mode 100644 Mastodon/Diffiable/Section/SelectedAccountSection.swift create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json create mode 100644 Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 93ae2ba09..acafa5fb2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -74,6 +74,9 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D42FF8E25C8228A004A627A /* UIButton.swift */; }; 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */; }; 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */; }; + 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */; }; + 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */; }; + 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */; }; 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */; }; 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */; }; 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */; }; @@ -489,6 +492,9 @@ 2D42FF8E25C8228A004A627A /* UIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIButton.swift; sourceTree = ""; }; 2D45E5BE25C9549700A6D639 /* PublicTimelineViewModel+State.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+State.swift"; sourceTree = ""; }; 2D46975D25C2A54100CF4AA9 /* NSLayoutConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLayoutConstraint.swift; sourceTree = ""; }; + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAccountCollectionViewCell.swift; sourceTree = ""; }; + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountSection.swift; sourceTree = ""; }; + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedAccountItem.swift; sourceTree = ""; }; 2D571B2E26004EC000540450 /* NavigationBarProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarProgressView.swift; sourceTree = ""; }; 2D59819A25E4A581000FB903 /* MastodonConfirmEmailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewController.swift; sourceTree = ""; }; 2D5981A025E4A593000FB903 /* MastodonConfirmEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonConfirmEmailViewModel.swift; sourceTree = ""; }; @@ -1020,6 +1026,14 @@ path = Button; sourceTree = ""; }; + 2D4AD89A2631659400613EFC /* CollectionViewCell */ = { + isa = PBXGroup; + children = ( + 2D4AD89B263165B500613EFC /* SuggestionAccountCollectionViewCell.swift */, + ); + path = CollectionViewCell; + sourceTree = ""; + }; 2D59819925E4A55C000FB903 /* ConfirmEmail */ = { isa = PBXGroup; children = ( @@ -1116,6 +1130,7 @@ DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */, 2DE0FAC02615F04D00CDF649 /* RecommendHashTagSection.swift */, 2DE0FACD2615F7AD00CDF649 /* RecommendAccountSection.swift */, + 2D4AD8A126316CD200613EFC /* SelectedAccountSection.swift */, 2D35237926256D920031AF25 /* NotificationSection.swift */, 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, @@ -1170,6 +1185,7 @@ children = ( 2D7631B225C159F700929FB9 /* Item.swift */, 2D198642261BF09500F0B013 /* SearchResultItem.swift */, + 2D4AD8A726316D3500613EFC /* SelectedAccountItem.swift */, 2D7867182625B77500211898 /* NotificationItem.swift */, DB4481CB25EE2AFE00BEFB67 /* PollItem.swift */, DB1E347725F519300079D7DF /* PickServerItem.swift */, @@ -1193,6 +1209,7 @@ children = ( 2DAC9E37262FC2320062E1A6 /* SuggestionAccountViewController.swift */, 2DAC9E3D262FC2400062E1A6 /* SuggestionAccountViewModel.swift */, + 2D4AD89A2631659400613EFC /* CollectionViewCell */, 2DAC9E43262FC9DE0062E1A6 /* TableViewCell */, ); path = SuggestionAccount; @@ -2427,6 +2444,7 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, + 2D4AD8A826316D3500613EFC /* SelectedAccountItem.swift in Sources */, DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */, DB8AF52F25C13561002E6C99 /* DocumentStore.swift in Sources */, 2D206B7225F5D27F00143C56 /* AudioContainerView.swift in Sources */, @@ -2554,6 +2572,7 @@ DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */, 2D35237A26256D920031AF25 /* NotificationSection.swift in Sources */, DB084B5725CBC56C00F898ED /* Status.swift in Sources */, + 2D4AD89C263165B500613EFC /* SuggestionAccountCollectionViewCell.swift in Sources */, DB447691260B406600B66B82 /* CustomEmojiPickerItemCollectionViewCell.swift in Sources */, DB9282B225F3222800823B15 /* PickServerEmptyStateView.swift in Sources */, DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, @@ -2618,6 +2637,7 @@ DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, + 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift new file mode 100644 index 000000000..2e85efc16 --- /dev/null +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -0,0 +1,38 @@ +// +// SelectedAccountItem.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import CoreData + +enum SelectedAccountItem { + case accountObjectID(accountObjectID: NSManagedObjectID) + case placeHolder(uuid: UUID) +} + +extension SelectedAccountItem: Equatable { + static func == (lhs: SelectedAccountItem, rhs: SelectedAccountItem) -> Bool { + switch (lhs, rhs) { + case (.accountObjectID(let idLeft), .accountObjectID(let idRight)): + return idLeft == idRight + case (.placeHolder(let uuidLeft), .placeHolder(let uuidRight)): + return uuidLeft == uuidRight + default: + return false + } + } +} + +extension SelectedAccountItem: Hashable { + func hash(into hasher: inout Hasher) { + switch self { + case .accountObjectID(let id): + hasher.combine(id) + case .placeHolder(let id): + hasher.combine(id.uuidString) + } + } +} diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift new file mode 100644 index 000000000..0efd9aebc --- /dev/null +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -0,0 +1,35 @@ +// +// SelectedAccountSection.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit + +enum SelectedAccountSection: Equatable, Hashable { + case main +} + +extension SelectedAccountSection { + static func collectionViewDiffableDataSource( + for collectionView: UICollectionView, + managedObjectContext: NSManagedObjectContext + ) -> UICollectionViewDiffableDataSource { + UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item -> UICollectionViewCell? in + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self), for: indexPath) as! SuggestionAccountCollectionViewCell + switch item { + case .accountObjectID(let objectID): + let user = managedObjectContext.object(with: objectID) as! MastodonUser + cell.config(with: user) + case .placeHolder( _): + cell.configAsPlaceHolder() + } + return cell + } + } +} diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index ce9e33e2b..35111dde1 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -69,6 +69,7 @@ internal enum Asset { internal static let highlight = ColorAsset(name: "Colors/Label/highlight") internal static let primary = ColorAsset(name: "Colors/Label/primary") internal static let secondary = ColorAsset(name: "Colors/Label/secondary") + internal static let tertiary = ColorAsset(name: "Colors/Label/tertiary") } internal enum Notification { internal static let favourite = ColorAsset(name: "Colors/Notification/favourite") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json new file mode 100644 index 000000000..d4f558bfd --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/Label/tertiary.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.300", + "blue" : "67", + "green" : "60", + "red" : "60" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 26ef485ee..717519464 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -125,7 +125,6 @@ final class HomeTimelineViewModel: NSObject { .store(in: &disposeBag) homeTimelineNeedRefresh - .debounce(for: 0.3, scheduler: DispatchQueue.main) .sink { [weak self] _ in self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift index c74560386..948d22b0f 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileRelationshipActionButton.swift @@ -9,7 +9,7 @@ import UIKit final class ProfileRelationshipActionButton: RoundedEdgesButton { - let actvityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) activityIndicatorView.color = .white return activityIndicatorView @@ -31,15 +31,15 @@ extension ProfileRelationshipActionButton { private func _init() { titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) - actvityIndicatorView.translatesAutoresizingMaskIntoConstraints = false - addSubview(actvityIndicatorView) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) NSLayoutConstraint.activate([ - actvityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), - actvityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) - actvityIndicatorView.hidesWhenStopped = true - actvityIndicatorView.stopAnimating() + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.stopAnimating() } } @@ -52,13 +52,13 @@ extension ProfileRelationshipActionButton { setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .highlighted) setBackgroundImage(.placeholder(color: actionOptionSet.backgroundColor.withAlphaComponent(0.5)), for: .disabled) - actvityIndicatorView.stopAnimating() + activityIndicatorView.stopAnimating() if let option = actionOptionSet.highPriorityAction(except: .editOptions), option == .blocked || option == .suspended { isEnabled = false } else if actionOptionSet.contains(.updating) { isEnabled = false - actvityIndicatorView.startAnimating() + activityIndicatorView.startAnimating() } else { isEnabled = true } diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift new file mode 100644 index 000000000..bb4e422a4 --- /dev/null +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -0,0 +1,60 @@ +// +// SuggestionAccountCollectionViewCell.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/22. +// + +import Foundation +import UIKit +import CoreDataStack + +class SuggestionAccountCollectionViewCell: UICollectionViewCell { + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.layer.cornerRadius = 4 + imageView.clipsToBounds = true + imageView.image = UIImage.placeholder(color: .systemFill) + return imageView + }() + + func configAsPlaceHolder() { + imageView.tintColor = Asset.Colors.Label.tertiary.color + imageView.image = UIImage.placeholder(color: .systemFill) + } + func config(with mastodonUser: MastodonUser) { + imageView.af.setImage( + withURL: URL(string: mastodonUser.avatar)!, + placeholderImage: UIImage.placeholder(color: .systemFill), + imageTransition: .crossDissolve(0.2) + ) + } + override func prepareForReuse() { + super.prepareForReuse() + } + + override init(frame: CGRect) { + super.init(frame: .zero) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } +} + +extension SuggestionAccountCollectionViewCell { + + private func configure() { + contentView.addSubview(imageView) + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: contentView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 9cc6f33a2..f36dc8da9 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -46,13 +46,16 @@ class SuggestionAccountViewController: UIViewController, NeedsDependency { return label }() - let avatarStackView: UIStackView = { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.distribution = .equalSpacing - stackView.alignment = .center - stackView.spacing = 15 - return stackView + let selectedCollectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.scrollDirection = .horizontal + let view = ControlContainableCollectionView(frame: .zero, collectionViewLayout: flowLayout) + view.register(SuggestionAccountCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: SuggestionAccountCollectionViewCell.self)) + view.backgroundColor = .clear + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + view.layer.masksToBounds = false + return view }() deinit { @@ -70,6 +73,7 @@ extension SuggestionAccountViewController { target: self, action: #selector(SuggestionAccountViewController.doneButtonDidClick(_:))) + tableView.delegate = self tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -85,6 +89,8 @@ extension SuggestionAccountViewController { delegate: self ) + viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -94,6 +100,17 @@ extension SuggestionAccountViewController { .store(in: &disposeBag) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + let avatarImageViewHeight: Double = 56 + let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) + viewModel.headerPlaceholderCount = avatarImageViewCount + viewModel.applySelectedCollectionViewDataSource(accounts: []) + } func setupHeader(accounts: [NSManagedObjectID]) { if accounts.isEmpty { return @@ -106,56 +123,89 @@ extension SuggestionAccountViewController { tableHeader.trailingAnchor.constraint(equalTo: followExplainLabel.trailingAnchor, constant: 20), ]) - avatarStackView.translatesAutoresizingMaskIntoConstraints = false - tableHeader.addSubview(avatarStackView) + selectedCollectionView.translatesAutoresizingMaskIntoConstraints = false + tableHeader.addSubview(selectedCollectionView) NSLayoutConstraint.activate([ - avatarStackView.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), - avatarStackView.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), - avatarStackView.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), - avatarStackView.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), + selectedCollectionView.frameLayoutGuide.topAnchor.constraint(equalTo: followExplainLabel.topAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.leadingAnchor.constraint(equalTo: tableHeader.leadingAnchor, constant: 20), + selectedCollectionView.frameLayoutGuide.trailingAnchor.constraint(equalTo: tableHeader.trailingAnchor), + selectedCollectionView.frameLayoutGuide.bottomAnchor.constraint(equalTo: tableHeader.bottomAnchor), ]) - let avatarImageViewHeight: Double = 56 - let avatarImageViewCount = Int(floor((Double(tableView.frame.width) - 20) / (avatarImageViewHeight + 15))) - let count = min(avatarImageViewCount, accounts.count) - for i in 0 ..< count { - let account = context.managedObjectContext.object(with: accounts[i]) as! MastodonUser - let imageView = UIImageView() - imageView.layer.cornerRadius = 6 - imageView.clipsToBounds = true - imageView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - imageView.heightAnchor.constraint(equalToConstant: CGFloat(avatarImageViewHeight)), - ]) - if let url = account.avatarImageURL() { - imageView.af.setImage( - withURL: url, - placeholderImage: UIImage.placeholder(color: .systemFill), - imageTransition: .crossDissolve(0.2) - ) - } - avatarStackView.addArrangedSubview(imageView) - } + selectedCollectionView.delegate = self tableView.tableHeaderView = tableHeader } } -extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) { - let selected = !sender.isSelected - sender.isSelected = !sender.isSelected - if selected { - viewModel.selectedAccounts.append(objectID) - } else { - viewModel.selectedAccounts.removeAll { $0 == objectID } +extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return 15 + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: 56, height: 56) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } + guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } + switch item { + case .accountObjectID(let accountObjectID): + let mastodonUser = context.managedObjectContext.object(with: accountObjectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + default: + break } } } +extension SuggestionAccountViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + guard let objectID = diffableDataSource.itemIdentifier(for: indexPath) else { return } + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + let viewModel = ProfileViewModel(context: context, optionalMastodonUser: mastodonUser) + DispatchQueue.main.async { + self.coordinator.present(scene: .profile(viewModel: viewModel), from: self, transition: .show) + } + } +} + +extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { + let selected = !viewModel.selectedAccounts.contains(objectID) + cell.startAnimating() + viewModel.followAction(objectID: objectID)? + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + cell.stopAnimating() + switch completion { + case .failure(let error): + os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + if selected { + self.viewModel.selectedAccounts.append(objectID) + } else { + self.viewModel.selectedAccounts.removeAll { $0 == objectID } + } + cell.button.isSelected = selected + self.viewModel.selectedAccountsDidChange.send() + } + }, receiveValue: { relationShip in + }) + .store(in: &disposeBag) + } +} + extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - viewModel.followAction() + if viewModel.selectedAccounts.count > 0 { + viewModel.delegate?.homeTimelineNeedRefresh.send() + } } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index ac8803188..e08e820e4 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -27,16 +27,20 @@ final class SuggestionAccountViewModel: NSObject { // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) var selectedAccounts = [NSManagedObjectID]() + let selectedAccountsDidChange = PassthroughSubject() + var headerPlaceholderCount: Int? var suggestionAccountsFallback = PassthroughSubject() var diffableDataSource: UITableViewDiffableDataSource? { didSet(value) { if !accounts.value.isEmpty { - applyDataSource(accounts: accounts.value) + applyTableViewDataSource(accounts: accounts.value) } } } + var collectionDiffableDataSource: UICollectionViewDiffableDataSource? + init(context: AppContext, accounts: [NSManagedObjectID]? = nil) { self.context = context @@ -45,7 +49,8 @@ final class SuggestionAccountViewModel: NSObject { self.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in - self?.applyDataSource(accounts: accounts) + self?.applyTableViewDataSource(accounts: accounts) + self?.applySelectedCollectionViewDataSource(accounts: []) } .store(in: &disposeBag) @@ -53,6 +58,13 @@ final class SuggestionAccountViewModel: NSObject { self.accounts.value = accounts } + selectedAccountsDidChange + .sink { [weak self] _ in + if let selectedAccout = self?.selectedAccounts { + self?.applySelectedCollectionViewDataSource(accounts: selectedAccout) + } + } + .store(in: &disposeBag) if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -102,13 +114,30 @@ final class SuggestionAccountViewModel: NSObject { .store(in: &disposeBag) } - func applyDataSource(accounts: [NSManagedObjectID]) { + func applyTableViewDataSource(accounts: [NSManagedObjectID]) { guard let dataSource = diffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) snapshot.appendItems(accounts, toSection: .main) dataSource.apply(snapshot, animatingDifferences: false, completion: nil) } + + func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { + guard let count = headerPlaceholderCount else { return } + guard let dataSource = collectionDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let placeholderCount = count - accounts.count + let accountItems = accounts.map { SelectedAccountItem.accountObjectID(accountObjectID: $0) } + snapshot.appendItems(accountItems, toSection: .main) + + if placeholderCount > 0 { + for _ in 0 ..< placeholderCount { + snapshot.appendItems([SelectedAccountItem.placeHolder(uuid: UUID())], toSection: .main) + } + } + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } func receiveAccounts(ids: [String]) { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { @@ -135,25 +164,14 @@ final class SuggestionAccountViewModel: NSObject { } } - func followAction() { - guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - for objectID in selectedAccounts { - let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser - context.apiService.toggleFollow( - for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: false - ) - .sink { completion in - switch completion { - case .failure(let error): - os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - self.delegate?.homeTimelineNeedRefresh.send() - } - } receiveValue: { _ in - } - .store(in: &disposeBag) - } + func followAction(objectID: NSManagedObjectID) -> AnyPublisher, Error>? { + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return nil } + + let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser + return context.apiService.toggleFollow( + for: mastodonUser, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + needFeedback: false + ) } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index 8f564ec31..d81d8e0e0 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -13,7 +13,7 @@ import MastodonSDK import UIKit protocol SuggestionAccountTableViewCellDelegate: AnyObject { - func accountButtonPressed(objectID: NSManagedObjectID, sender: UIButton) + func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) } final class SuggestionAccountTableViewCell: UITableViewCell { @@ -43,7 +43,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { return label }() - lazy var button: HighlightDimmableButton = { + let buttonContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + let button: HighlightDimmableButton = { let button = HighlightDimmableButton(type: .custom) if let plusImage = UIImage(systemName: "plus.circle", withConfiguration: UIImage.SymbolConfiguration(pointSize: 22, weight: .regular))?.withRenderingMode(.alwaysTemplate) { button.setImage(plusImage, for: .normal) @@ -53,6 +59,13 @@ final class SuggestionAccountTableViewCell: UITableViewCell { } return button }() + + let activityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.color = .white + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() override func prepareForReuse() { super.prepareForReuse() @@ -112,8 +125,25 @@ extension SuggestionAccountTableViewCell { containerStackView.addArrangedSubview(textStackView) textStackView.setContentHuggingPriority(.defaultLow - 1, for: .horizontal) + buttonContainer.translatesAutoresizingMaskIntoConstraints = false + containerStackView.addArrangedSubview(buttonContainer) + NSLayoutConstraint.activate([ + buttonContainer.widthAnchor.constraint(equalToConstant: 24).priority(.required - 1), + buttonContainer.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), + ]) + buttonContainer.setContentHuggingPriority(.required - 1, for: .horizontal) + + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(button) + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + buttonContainer.addSubview(button) + buttonContainer.addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + buttonContainer.centerXAnchor.constraint(equalTo: activityIndicatorView.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: activityIndicatorView.centerYAnchor), + buttonContainer.centerXAnchor.constraint(equalTo: button.centerXAnchor), + buttonContainer.centerYAnchor.constraint(equalTo: button.centerYAnchor), + ]) } func config(with account: MastodonUser, isSelected: Bool) { @@ -130,7 +160,7 @@ extension SuggestionAccountTableViewCell { button.publisher(for: .touchUpInside) .sink { [weak self] _ in guard let self = self else { return } - self.delegate?.accountButtonPressed(objectID: account.objectID, sender: self.button) + self.delegate?.accountButtonPressed(objectID: account.objectID, cell: self) } .store(in: &disposeBag) button.publisher(for: \.isSelected) @@ -141,6 +171,23 @@ extension SuggestionAccountTableViewCell { self?.button.tintColor = Asset.Colors.Label.secondary.color } } - .store(in: &self.disposeBag) + .store(in: &disposeBag) + activityIndicatorView.publisher(for: \.isHidden) + .receive(on: DispatchQueue.main) + .sink { [weak self] isHidden in + self?.button.isHidden = !isHidden + } + .store(in: &disposeBag) + + } + + func startAnimating() { + activityIndicatorView.isHidden = false + activityIndicatorView.startAnimating() + } + + func stopAnimating() { + activityIndicatorView.stopAnimating() + activityIndicatorView.isHidden = true } } From 67f813a9466c74757d946f803df33da6b75a0545 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 22 Apr 2021 20:32:54 +0800 Subject: [PATCH 296/400] fix: update followState when view will appear --- .../Diffiable/Item/SelectedAccountItem.swift | 2 +- .../Section/SelectedAccountSection.swift | 2 +- .../SuggestionAccountCollectionViewCell.swift | 11 ++++--- .../SuggestionAccountViewController.swift | 14 ++++---- .../SuggestionAccountViewModel.swift | 32 +++++++++++++++++-- .../SuggestionAccountTableViewCell.swift | 5 ++- 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/Mastodon/Diffiable/Item/SelectedAccountItem.swift b/Mastodon/Diffiable/Item/SelectedAccountItem.swift index 2e85efc16..dbfe25cea 100644 --- a/Mastodon/Diffiable/Item/SelectedAccountItem.swift +++ b/Mastodon/Diffiable/Item/SelectedAccountItem.swift @@ -5,8 +5,8 @@ // Created by sxiaojian on 2021/4/22. // -import Foundation import CoreData +import Foundation enum SelectedAccountItem { case accountObjectID(accountObjectID: NSManagedObjectID) diff --git a/Mastodon/Diffiable/Section/SelectedAccountSection.swift b/Mastodon/Diffiable/Section/SelectedAccountSection.swift index 0efd9aebc..4f18ef873 100644 --- a/Mastodon/Diffiable/Section/SelectedAccountSection.swift +++ b/Mastodon/Diffiable/Section/SelectedAccountSection.swift @@ -26,7 +26,7 @@ extension SelectedAccountSection { case .accountObjectID(let objectID): let user = managedObjectContext.object(with: objectID) as! MastodonUser cell.config(with: user) - case .placeHolder( _): + case .placeHolder: cell.configAsPlaceHolder() } return cell diff --git a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift index bb4e422a4..a973e1c52 100644 --- a/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/CollectionViewCell/SuggestionAccountCollectionViewCell.swift @@ -5,9 +5,9 @@ // Created by sxiaojian on 2021/4/22. // +import CoreDataStack import Foundation import UIKit -import CoreDataStack class SuggestionAccountCollectionViewCell: UICollectionViewCell { let imageView: UIImageView = { @@ -18,11 +18,12 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { imageView.image = UIImage.placeholder(color: .systemFill) return imageView }() - + func configAsPlaceHolder() { imageView.tintColor = Asset.Colors.Label.tertiary.color imageView.image = UIImage.placeholder(color: .systemFill) } + func config(with mastodonUser: MastodonUser) { imageView.af.setImage( withURL: URL(string: mastodonUser.avatar)!, @@ -30,15 +31,16 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { imageTransition: .crossDissolve(0.2) ) } + override func prepareForReuse() { super.prepareForReuse() } - + override init(frame: CGRect) { super.init(frame: .zero) configure() } - + required init?(coder: NSCoder) { super.init(coder: coder) configure() @@ -46,7 +48,6 @@ class SuggestionAccountCollectionViewCell: UICollectionViewCell { } extension SuggestionAccountCollectionViewCell { - private func configure() { contentView.addSubview(imageView) imageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index f36dc8da9..017c1ee3d 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -90,7 +90,7 @@ extension SuggestionAccountViewController { ) viewModel.collectionDiffableDataSource = SelectedAccountSection.collectionViewDiffableDataSource(for: selectedCollectionView, managedObjectContext: context.managedObjectContext) - + viewModel.accounts .receive(on: DispatchQueue.main) .sink { [weak self] accounts in @@ -103,7 +103,9 @@ extension SuggestionAccountViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) tableView.deselectRow(with: transitionCoordinator, animated: animated) + viewModel.checkAccountsFollowState() } + override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() let avatarImageViewHeight: Double = 56 @@ -111,6 +113,7 @@ extension SuggestionAccountViewController { viewModel.headerPlaceholderCount = avatarImageViewCount viewModel.applySelectedCollectionViewDataSource(accounts: []) } + func setupHeader(accounts: [NSManagedObjectID]) { if accounts.isEmpty { return @@ -138,15 +141,14 @@ extension SuggestionAccountViewController { } extension SuggestionAccountViewController: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { - return 15 + 15 } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - return CGSize(width: 56, height: 56) + CGSize(width: 56, height: 56) } - + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let diffableDataSource = viewModel.collectionDiffableDataSource else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } @@ -195,7 +197,7 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat cell.button.isSelected = selected self.viewModel.selectedAccountsDidChange.send() } - }, receiveValue: { relationShip in + }, receiveValue: { _ in }) .store(in: &disposeBag) } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index e08e820e4..3a89d1431 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -23,6 +23,7 @@ final class SuggestionAccountViewModel: NSObject { // input let context: AppContext + let currentMastodonUser = CurrentValueSubject(nil) weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) @@ -60,11 +61,23 @@ final class SuggestionAccountViewModel: NSObject { selectedAccountsDidChange .sink { [weak self] _ in - if let selectedAccout = self?.selectedAccounts { - self?.applySelectedCollectionViewDataSource(accounts: selectedAccout) - } + guard let self = self else { return } + self.applyTableViewDataSource(accounts: self.accounts.value) + self.applySelectedCollectionViewDataSource(accounts: self.selectedAccounts) } .store(in: &disposeBag) + + context.authenticationService.activeMastodonAuthentication + .sink { [weak self] activeMastodonAuthentication in + guard let self = self else { return } + guard let activeMastodonAuthentication = activeMastodonAuthentication else { + self.currentMastodonUser.value = nil + return + } + self.currentMastodonUser.value = activeMastodonAuthentication.user + } + .store(in: &disposeBag) + if accounts == nil || (accounts ?? []).isEmpty { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -174,4 +187,17 @@ final class SuggestionAccountViewModel: NSObject { needFeedback: false ) } + + func checkAccountsFollowState() { + guard let currentMastodonUser = currentMastodonUser.value else { + return + } + let users = accounts.value.compactMap { context.managedObjectContext.object(with: $0) as? MastodonUser } + let followingUsers = users.filter { user -> Bool in + let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + return isFollowing + }.map(\.objectID) + selectedAccounts = followingUsers + selectedAccountsDidChange.send() + } } diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index d81d8e0e0..a550fd889 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -101,14 +101,14 @@ extension SuggestionAccountTableViewCell { containerStackView.topAnchor.constraint(equalTo: contentView.topAnchor), containerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), containerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + containerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) _imageView.translatesAutoresizingMaskIntoConstraints = false containerStackView.addArrangedSubview(_imageView) NSLayoutConstraint.activate([ _imageView.widthAnchor.constraint(equalToConstant: 42).priority(.required - 1), - _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1) + _imageView.heightAnchor.constraint(equalToConstant: 42).priority(.required - 1), ]) let textStackView = UIStackView() @@ -178,7 +178,6 @@ extension SuggestionAccountTableViewCell { self?.button.isHidden = !isHidden } .store(in: &disposeBag) - } func startAnimating() { From 3810588abb421b88888b17b96eae9cb65282bc0e Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 22 Apr 2021 22:58:21 +0800 Subject: [PATCH 297/400] chor: restyle localized string --- Localization/app.json | 4 ++-- Mastodon/Generated/Strings.swift | 6 +++--- Mastodon/Resources/en.lproj/Localizable.strings | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index ec908384b..fcd960293 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -399,8 +399,8 @@ "content1": "Are there any other posts you’d like to add to the report?", "content2": "Is there anything the moderators should know about this report?", "send": "Send Report", - "skipToSend": "Send without comment", - "textPlaceHolder": "|Type or paste additional comments" + "skip_to_send": "Send without comment", + "text_placeholder": "Type or paste additional comments" } } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 0972b5a20..d9baf8665 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -537,13 +537,13 @@ internal enum L10n { /// Send Report internal static let send = L10n.tr("Localizable", "Scene.Report.Send") /// Send without comment - internal static let skiptosend = L10n.tr("Localizable", "Scene.Report.Skiptosend") + internal static let skipToSend = L10n.tr("Localizable", "Scene.Report.SkipToSend") /// Step 1 of 2 internal static let step1 = L10n.tr("Localizable", "Scene.Report.Step1") /// Step 2 of 2 internal static let step2 = L10n.tr("Localizable", "Scene.Report.Step2") - /// |Type or paste additional comments - internal static let textplaceholder = L10n.tr("Localizable", "Scene.Report.Textplaceholder") + /// Type or paste additional comments + internal static let textPlaceholder = L10n.tr("Localizable", "Scene.Report.TextPlaceholder") /// Report %@ internal static func title(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Report.Title", String(describing: p1)) diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 346734666..e16d3e886 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -173,10 +173,10 @@ tap the link to confirm your account."; "Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; "Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; "Scene.Report.Send" = "Send Report"; -"Scene.Report.Skiptosend" = "Send without comment"; +"Scene.Report.SkipToSend" = "Send without comment"; "Scene.Report.Step1" = "Step 1 of 2"; "Scene.Report.Step2" = "Step 2 of 2"; -"Scene.Report.Textplaceholder" = "|Type or paste additional comments"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; "Scene.Report.Title" = "Report %@"; "Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; From 1411bcadf61245452d7d830508c8ea935e3808a1 Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 22 Apr 2021 22:58:55 +0800 Subject: [PATCH 298/400] doc: update doc for Report API --- MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift index eac7f64f7..a0afabb2b 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -19,7 +19,7 @@ extension Mastodon.API.Reports { /// 1.1 - added /// 2.3.0 - add forward parameter /// # Reference - /// [Document](https://docs.joinmastodon.org/methods/search/) + /// [Document](https://docs.joinmastodon.org/methods/accounts/reports/) /// - Parameters: /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" From 21264fead19a386ce9322c304360014b5ca1a7b5 Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 22 Apr 2021 23:01:07 +0800 Subject: [PATCH 299/400] refactor: To pass the ViewModel to coordinator --- Mastodon/Coordinator/SceneCoordinator.swift | 21 ++++++------------- ...meTimelineViewController+DebugAction.swift | 17 +++++++++++++-- .../Scene/Settings/SettingsViewModel.swift | 6 ++---- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 93393d542..18d76e446 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -65,8 +65,8 @@ extension SceneCoordinator { case safari(url: URL) case alertController(alertController: UIAlertController) case activityViewController(activityViewController: UIActivityViewController, sourceView: UIView?, barButtonItem: UIBarButtonItem?) - case settings - case report(userId: String, statusId: String?) + case settings(viewModel: SettingsViewModel) + case report(viewModel: ReportViewModel) #if DEBUG case publicTimeline #endif @@ -265,22 +265,13 @@ private extension SceneCoordinator { activityViewController.popoverPresentationController?.sourceView = sourceView activityViewController.popoverPresentationController?.barButtonItem = barButtonItem viewController = activityViewController - case .settings: + case .settings(let viewModel): let _viewController = SettingsViewController() - _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) + _viewController.viewModel = viewModel viewController = _viewController - case .report(let userId, let statusId): - guard let authenticationBox = appContext.authenticationService.activeMastodonAuthenticationBox.value else { - return nil - } + case .report(let viewModel): let _viewController = ReportViewController() - _viewController.viewModel = ReportViewModel( - context: appContext, - coordinator: self, - domain: authenticationBox.domain, - userId: userId, - statusId: statusId - ) + _viewController.viewModel = viewModel viewController = _viewController #if DEBUG case .publicTimeline: diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 459652008..fca20c92b 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -332,7 +332,12 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + let viewModel = SettingsViewModel(context: context) + coordinator.present( + scene: .settings(viewModel: viewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) } @objc private func showReportAction(_ sender: UIAction) { @@ -350,12 +355,20 @@ extension HomeTimelineViewController { guard let userId = accountTextField.text else { return } guard let statusId = statusTextField.text else { return } + guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } // itodo: delete them // 31803 // 106093402888557459 + let viewModel = ReportViewModel( + context: self.context, + coordinator: self.coordinator, + domain: authenticationBox.domain, + userId: userId, + statusId: statusId + ) self.coordinator.present( - scene: .report(userId: userId, statusId: statusId), + scene: .report(viewModel: viewModel), from: self, transition: .modal(animated: true, completion: nil)) } alertController.addAction(showAction) diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 470617aeb..57371f92b 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,10 +13,9 @@ import MastodonSDK import UIKit import os.log -class SettingsViewModel: NSObject, NeedsDependency { +class SettingsViewModel: NSObject { // confirm set only once weak var context: AppContext! { willSet { precondition(context == nil) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() @@ -87,9 +86,8 @@ class SettingsViewModel: NSObject, NeedsDependency { struct Output { } - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext) { self.context = context - self.coordinator = coordinator super.init() } From ce3f4f5e96adbb1749214d3a62fd6e4e2ac2b65b Mon Sep 17 00:00:00 2001 From: ihugo Date: Thu, 22 Apr 2021 23:02:24 +0800 Subject: [PATCH 300/400] refactor: Use single file organization classes. --- Mastodon.xcodeproj/project.pbxproj | 12 +- Mastodon/Scene/Report/ReportFooterView.swift | 108 ++++++++++ Mastodon/Scene/Report/ReportHeaderView.swift | 115 ++++++++++ Mastodon/Scene/Report/ReportView.swift | 201 ------------------ .../Scene/Report/ReportViewController.swift | 12 +- Mastodon/Scene/Report/ReportViewModel.swift | 16 +- 6 files changed, 245 insertions(+), 219 deletions(-) create mode 100644 Mastodon/Scene/Report/ReportFooterView.swift create mode 100644 Mastodon/Scene/Report/ReportHeaderView.swift delete mode 100644 Mastodon/Scene/Report/ReportView.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e3f553ba7..d53252220 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -137,6 +137,7 @@ 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */; }; 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E055726319E47006E3C53 /* ReportFooterView.swift */; }; 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */; }; 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; @@ -149,7 +150,7 @@ 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */; }; 5B90C48B26259C120002E742 /* APIService+CoreData+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */; }; 5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */; }; - 5BB04FDB262EA3070043BFF6 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportView.swift */; }; + 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */; }; 5BB04FE9262EFC300043BFF6 /* ReportedStatusTableviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */; }; 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */; }; 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */; }; @@ -562,6 +563,7 @@ 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Provider.swift"; sourceTree = ""; }; 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; @@ -574,7 +576,7 @@ 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Subscriptions.swift"; sourceTree = ""; }; 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Subscriptions.swift"; sourceTree = ""; }; 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportViewController.swift; sourceTree = ""; }; - 5BB04FDA262EA3070043BFF6 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportHeaderView.swift; sourceTree = ""; }; 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportedStatusTableviewCell.swift; sourceTree = ""; }; 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Data.swift"; sourceTree = ""; }; 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportSection.swift; sourceTree = ""; }; @@ -1250,7 +1252,8 @@ 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */, 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, - 5BB04FDA262EA3070043BFF6 /* ReportView.swift */, + 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */, + 5B8E055726319E47006E3C53 /* ReportFooterView.swift */, 5BB04FE8262EFC300043BFF6 /* ReportedStatusTableviewCell.swift */, ); path = Report; @@ -2369,6 +2372,7 @@ 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */, DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */, 5BB04FEF262F0DCB0043BFF6 /* ReportViewModel+Data.swift in Sources */, + 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, 0FB3D33225E5F50E00AAD544 /* PickServerSearchCell.swift in Sources */, DB71FD5225F8CCAA00512AE1 /* APIService+Status.swift in Sources */, @@ -2542,7 +2546,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, - 5BB04FDB262EA3070043BFF6 /* ReportView.swift in Sources */, + 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, diff --git a/Mastodon/Scene/Report/ReportFooterView.swift b/Mastodon/Scene/Report/ReportFooterView.swift new file mode 100644 index 000000000..16948f58e --- /dev/null +++ b/Mastodon/Scene/Report/ReportFooterView.swift @@ -0,0 +1,108 @@ +// +// ReportFooterView.swift +// Mastodon +// +// Created by ihugo on 2021/4/22. +// + +import UIKit + +final class ReportFooterView: UIView { + enum Step: Int { + case one + case two + } + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .fill + view.spacing = 8 + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var nextStepButton: PrimaryActionButton = { + let button = PrimaryActionButton() + button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + lazy var skipButton: UIButton = { + let button = UIButton(type: .system) + button.tintColor = Asset.Colors.brandBlue.color + button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) + case .two: + nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) + skipButton.setTitle(L10n.Scene.Report.skipToSend, for: .normal) + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + + stackview.addArrangedSubview(nextStepButton) + stackview.addArrangedSubview(skipButton) + addSubview(stackview) + + NSLayoutConstraint.activate([ + stackview.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.continuTopMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.safeAreaLayoutGuide.bottomAnchor, + constant: -1 * ReportView.skipBottomMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ), + nextStepButton.heightAnchor.constraint( + equalToConstant: ReportView.buttonHeight + ), + skipButton.heightAnchor.constraint( + equalTo: nextStepButton.heightAnchor + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportFooterView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview(width: 375) { () -> UIView in + return ReportFooterView(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) + } + .previewLayout(.fixed(width: 375, height: 164)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift new file mode 100644 index 000000000..bace3ada5 --- /dev/null +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -0,0 +1,115 @@ +// +// ReportView.swift +// Mastodon +// +// Created by ihugo on 2021/4/20. +// + +import UIKit + +struct ReportView { + static var horizontalMargin: CGFloat { return 12 } + static var verticalMargin: CGFloat { return 22 } + static var buttonHeight: CGFloat { return 46 } + static var skipBottomMargin: CGFloat { return 8 } + static var continuTopMargin: CGFloat { return 22 } +} + +final class ReportHeaderView: UIView { + enum Step: Int { + case one + case two + } + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.secondary.color + label.font = UIFontMetrics(forTextStyle: .subheadline) + .scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.numberOfLines = 0 + return label + }() + + lazy var contentLabel: UILabel = { + let label = UILabel() + label.textColor = Asset.Colors.Label.primary.color + label.font = UIFontMetrics(forTextStyle: .title3) + .scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + label.numberOfLines = 0 + return label + }() + + lazy var stackview: UIStackView = { + let view = UIStackView() + view.axis = .vertical + view.alignment = .leading + view.spacing = 2 + return view + }() + + var step: Step = .one { + didSet { + switch step { + case .one: + titleLabel.text = L10n.Scene.Report.step1 + contentLabel.text = L10n.Scene.Report.content1 + case .two: + titleLabel.text = L10n.Scene.Report.step2 + contentLabel.text = L10n.Scene.Report.content2 + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + stackview.addArrangedSubview(titleLabel) + stackview.addArrangedSubview(contentLabel) + addSubview(stackview) + + stackview.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackview.safeAreaLayoutGuide.topAnchor.constraint( + equalTo: self.topAnchor, + constant: ReportView.verticalMargin + ), + stackview.leadingAnchor.constraint( + equalTo: self.readableContentGuide.leadingAnchor, + constant: ReportView.horizontalMargin + ), + stackview.bottomAnchor.constraint( + equalTo: self.bottomAnchor, + constant: -1 * ReportView.verticalMargin + ), + stackview.trailingAnchor.constraint( + equalTo: self.readableContentGuide.trailingAnchor, + constant: -1 * ReportView.horizontalMargin + ) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct ReportHeaderView_Previews: PreviewProvider { + static var previews: some View { + Group { + UIViewPreview { () -> UIView in + let view = ReportHeaderView() + view.step = .one + view.contentLabel.preferredMaxLayoutWidth = 335 + return view + } + .previewLayout(.fixed(width: 375, height: 110)) + } + } + +} + +#endif diff --git a/Mastodon/Scene/Report/ReportView.swift b/Mastodon/Scene/Report/ReportView.swift deleted file mode 100644 index 9166259fe..000000000 --- a/Mastodon/Scene/Report/ReportView.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// ReportView.swift -// Mastodon -// -// Created by ihugo on 2021/4/20. -// - -import UIKit - -struct ReportView { - static var horizontalMargin: CGFloat { return 12 } - static var verticalMargin: CGFloat { return 22 } - static var buttonHeight: CGFloat { return 46 } - static var skipBottomMargin: CGFloat { return 8 } - static var continuTopMargin: CGFloat { return 22 } -} - -final class ReportViewHeader: UIView { - enum Step: Int { - case one - case two - } - - lazy var titleLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFont.preferredFont(forTextStyle: .subheadline) - label.numberOfLines = 0 - return label - }() - - lazy var contentLabel: UILabel = { - let label = UILabel() - label.textColor = Asset.Colors.Label.primary.color - label.font = UIFont.preferredFont(forTextStyle: .title3) - label.numberOfLines = 0 - return label - }() - - lazy var stackview: UIStackView = { - let view = UIStackView() - view.axis = .vertical - view.alignment = .leading - view.spacing = 2 - return view - }() - - var step: Step = .one { - didSet { - switch step { - case .one: - titleLabel.text = L10n.Scene.Report.step1 - contentLabel.text = L10n.Scene.Report.content1 - case .two: - titleLabel.text = L10n.Scene.Report.step2 - contentLabel.text = L10n.Scene.Report.content2 - } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color - stackview.addArrangedSubview(titleLabel) - stackview.addArrangedSubview(contentLabel) - addSubview(stackview) - - stackview.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - stackview.safeAreaLayoutGuide.topAnchor.constraint( - equalTo: self.topAnchor, - constant: ReportView.verticalMargin - ), - stackview.leadingAnchor.constraint( - equalTo: self.readableContentGuide.leadingAnchor, - constant: ReportView.horizontalMargin - ), - stackview.bottomAnchor.constraint( - equalTo: self.bottomAnchor, - constant: -1 * ReportView.verticalMargin - ), - stackview.trailingAnchor.constraint( - equalTo: self.readableContentGuide.trailingAnchor, - constant: -1 * ReportView.horizontalMargin - ) - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -final class ReportViewFooter: UIView { - enum Step: Int { - case one - case two - } - - lazy var stackview: UIStackView = { - let view = UIStackView() - view.axis = .vertical - view.alignment = .fill - view.spacing = 8 - view.translatesAutoresizingMaskIntoConstraints = false - return view - }() - - lazy var nextStepButton: PrimaryActionButton = { - let button = PrimaryActionButton() - button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - lazy var skipButton: UIButton = { - let button = UIButton(type: .system) - button.tintColor = Asset.Colors.brandBlue.color - button.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - return button - }() - - var step: Step = .one { - didSet { - switch step { - case .one: - nextStepButton.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) - skipButton.setTitle(L10n.Common.Controls.Actions.skip, for: .normal) - case .two: - nextStepButton.setTitle(L10n.Scene.Report.send, for: .normal) - skipButton.setTitle(L10n.Scene.Report.skiptosend, for: .normal) - } - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - - self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color - - stackview.addArrangedSubview(nextStepButton) - stackview.addArrangedSubview(skipButton) - addSubview(stackview) - - NSLayoutConstraint.activate([ - stackview.topAnchor.constraint( - equalTo: self.topAnchor, - constant: ReportView.continuTopMargin - ), - stackview.leadingAnchor.constraint( - equalTo: self.readableContentGuide.leadingAnchor, - constant: ReportView.horizontalMargin - ), - stackview.bottomAnchor.constraint( - equalTo: self.safeAreaLayoutGuide.bottomAnchor, - constant: -1 * ReportView.skipBottomMargin - ), - stackview.trailingAnchor.constraint( - equalTo: self.readableContentGuide.trailingAnchor, - constant: -1 * ReportView.horizontalMargin - ), - nextStepButton.heightAnchor.constraint( - equalToConstant: ReportView.buttonHeight - ), - skipButton.heightAnchor.constraint( - equalTo: nextStepButton.heightAnchor - ) - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct ReportView_Previews: PreviewProvider { - static var previews: some View { - Group { - UIViewPreview { () -> UIView in - let view = ReportViewHeader() - view.step = .one - view.contentLabel.preferredMaxLayoutWidth = 335 - return view - } - .previewLayout(.fixed(width: 375, height: 110)) - - UIViewPreview(width: 375) { () -> UIView in - return ReportViewFooter(frame: CGRect(origin: .zero, size: CGSize(width: 375, height: 164))) - } - .previewLayout(.fixed(width: 375, height: 164)) - } - } - -} - -#endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index 7d46ae6b9..b00e4a3ee 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -30,14 +30,14 @@ class ReportViewController: UIViewController, NeedsDependency { let cancel = PassthroughSubject() // MAKK: - UI - lazy var header: ReportViewHeader = { - let view = ReportViewHeader() + lazy var header: ReportHeaderView = { + let view = ReportHeaderView() view.translatesAutoresizingMaskIntoConstraints = false return view }() - lazy var footer: ReportViewFooter = { - let view = ReportViewFooter() + lazy var footer: ReportFooterView = { + let view = ReportFooterView() view.translatesAutoresizingMaskIntoConstraints = false return view }() @@ -74,7 +74,7 @@ class ReportViewController: UIViewController, NeedsDependency { let textView = UITextView() textView.font = .preferredFont(forTextStyle: .body) textView.isScrollEnabled = false - textView.placeholder = L10n.Scene.Report.textplaceholder + textView.placeholder = L10n.Scene.Report.textPlaceholder textView.backgroundColor = .clear textView.delegate = self return textView @@ -194,7 +194,7 @@ class ReportViewController: UIViewController, NeedsDependency { }() navigationItem.title = L10n.Scene.Report.title( - "\(beReportedUser?.displayName ?? "@\(beReportedUser?.acct ?? "")")" + beReportedUser?.displayNameWithFallback ?? "" ) } diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index 94404af10..6feedc0b8 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -29,14 +29,14 @@ class ReportViewModel: NSObject, NeedsDependency { var selectedItems = [Item]() var comment: String? - internal var reportQuery: FileReportQuery - internal var disposeBag = Set() - internal let currentStep = CurrentValueSubject(.one) - internal let statusFetchedResultsController: StatusFetchedResultsController - internal var diffableDataSource: UITableViewDiffableDataSource? - internal let continueEnableSubject = CurrentValueSubject(false) - internal let sendEnableSubject = CurrentValueSubject(false) - internal let reportSuccess = PassthroughSubject() + var reportQuery: FileReportQuery + var disposeBag = Set() + let currentStep = CurrentValueSubject(.one) + let statusFetchedResultsController: StatusFetchedResultsController + var diffableDataSource: UITableViewDiffableDataSource? + let continueEnableSubject = CurrentValueSubject(false) + let sendEnableSubject = CurrentValueSubject(false) + let reportSuccess = PassthroughSubject() struct Input { let didToggleSelected: AnyPublisher From 008bb49d2d0679a9bd059d7d5ce6e9166c4701cf Mon Sep 17 00:00:00 2001 From: ihugo Date: Fri, 23 Apr 2021 09:37:18 +0800 Subject: [PATCH 301/400] fix: add selection state of report status --- Mastodon.xcodeproj/project.pbxproj | 4 - Mastodon/Diffiable/Item/Item.swift | 16 ++++ .../Diffiable/Section/ReportSection.swift | 8 +- .../Diffiable/Section/StatusSection.swift | 2 + .../Scene/Report/ReportViewModel+Data.swift | 11 +-- .../Report/ReportViewModel+Diffable.swift | 6 +- .../Report/ReportViewModel+Provider.swift | 86 ------------------- Mastodon/Scene/Report/ReportViewModel.swift | 58 +++---------- .../Report/ReportedStatusTableviewCell.swift | 5 -- .../MastodonSDK/API/Mastodon+API+Report.swift | 14 +++ 10 files changed, 54 insertions(+), 156 deletions(-) delete mode 100644 Mastodon/Scene/Report/ReportViewModel+Provider.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d53252220..f84e8384d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -135,7 +135,6 @@ 2DFAD5372617010500F9EE7C /* SearchingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DFAD5362617010500F9EE7C /* SearchingTableViewCell.swift */; }; 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */; }; 5B24BBDB262DB14800A9381B /* ReportViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */; }; - 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */; }; 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B24BBE1262DB19100A9381B /* APIService+Report.swift */; }; 5B8E055826319E47006E3C53 /* ReportFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B8E055726319E47006E3C53 /* ReportFooterView.swift */; }; 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C456262599800002E742 /* SettingsViewModel.swift */; }; @@ -561,7 +560,6 @@ 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; - 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Provider.swift"; sourceTree = ""; }; 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -1249,7 +1247,6 @@ children = ( 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */, 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */, - 5B24BBD9262DB14800A9381B /* ReportViewModel+Provider.swift */, 5BB04FEE262F0DCB0043BFF6 /* ReportViewModel+Data.swift */, 5BB04FD4262E7AFF0043BFF6 /* ReportViewController.swift */, 5BB04FDA262EA3070043BFF6 /* ReportHeaderView.swift */, @@ -2569,7 +2566,6 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, - 5B24BBDC262DB14800A9381B /* ReportViewModel+Provider.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index e169be66f..04a1262d5 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -32,6 +32,9 @@ enum Item { case bottomLoader case emptyStateHeader(attribute: EmptyStateHeaderAttribute) + + // reports + case reportStatus(objectID: NSManagedObjectID, attribute: ReportStatusAttribute) } extension Item { @@ -79,6 +82,15 @@ extension Item { hasher.combine(id) } } + + class ReportStatusAttribute: StatusAttribute { + var isSelected: Bool + + init(isSeparatorLineHidden: Bool = false, isSelected: Bool = false) { + self.isSelected = isSelected + super.init(isSeparatorLineHidden: isSeparatorLineHidden) + } + } } extension Item: Equatable { @@ -106,6 +118,8 @@ extension Item: Equatable { return true case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): return attributeLeft == attributeRight + case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): + return objectIDLeft == objectIDRight default: return false } @@ -139,6 +153,8 @@ extension Item: Hashable { hasher.combine(String(describing: Item.bottomLoader.self)) case .emptyStateHeader(let attribute): hasher.combine(attribute) + case .reportStatus(let objectID, _): + hasher.combine(objectID) } } } diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index 7567488c5..86a12a9a3 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -23,8 +23,7 @@ extension ReportSection { for tableView: UITableView, dependency: NeedsDependency, managedObjectContext: NSManagedObjectContext, - timestampUpdatePublisher: AnyPublisher, - reportdStatusDelegate: ReportedStatusTableViewCellDelegate + timestampUpdatePublisher: AnyPublisher ) -> UITableViewDiffableDataSource { UITableViewDiffableDataSource(tableView: tableView) {[ weak dependency @@ -32,7 +31,7 @@ extension ReportSection { guard let dependency = dependency else { return UITableViewCell() } switch item { - case .status(let objectID, let attribute): + case .reportStatus(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" @@ -49,8 +48,7 @@ extension ReportSection { ) } - let isSelected = reportdStatusDelegate.reportedStatus(cell: cell, isSelected: indexPath) - cell.setupSelected(isSelected) + cell.setupSelected(attribute.isSelected) return cell default: return nil diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4f09142a7..97e4cdf9c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -125,6 +125,8 @@ extension StatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) return cell + case .reportStatus: + return UITableViewCell() } } } diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index 4df2ccb20..005098b47 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -71,23 +71,24 @@ extension ReportViewModel { diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) } - var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:] + var oldSnapshotAttributeDict: [NSManagedObjectID : Item.ReportStatusAttribute] = [:] let oldSnapshot = diffableDataSource.snapshot() for item in oldSnapshot.itemIdentifiers { - guard case let .status(objectID, attribute) = item else { continue } + guard case let .reportStatus(objectID, attribute) = item else { continue } oldSnapshotAttributeDict[objectID] = attribute } for objectID in objectIDs { - let attribute = oldSnapshotAttributeDict[objectID] ?? Item.StatusAttribute() - let item = Item.status(objectID: objectID, attribute: attribute) + let attribute = oldSnapshotAttributeDict[objectID] ?? Item.ReportStatusAttribute() + let item = Item.reportStatus(objectID: objectID, attribute: attribute) items.append(item) guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } if status.id == self.statusId { - self.selectedItems.append(item) + attribute.isSelected = true + self.reportQuery.append(statusId: status.id) self.continueEnableSubject.send(true) } } diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift index 38f2edb19..f737381b1 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -13,8 +13,7 @@ import CoreDataStack extension ReportViewModel { func setupDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency, - reportdStatusDelegate: ReportedStatusTableViewCellDelegate + dependency: NeedsDependency ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() @@ -25,8 +24,7 @@ extension ReportViewModel { for: tableView, dependency: dependency, managedObjectContext: statusFetchedResultsController.fetchedResultsController.managedObjectContext, - timestampUpdatePublisher: timestampUpdatePublisher, - reportdStatusDelegate: reportdStatusDelegate + timestampUpdatePublisher: timestampUpdatePublisher ) // set empty section to make update animation top-to-bottom style diff --git a/Mastodon/Scene/Report/ReportViewModel+Provider.swift b/Mastodon/Scene/Report/ReportViewModel+Provider.swift deleted file mode 100644 index 052fc2ba1..000000000 --- a/Mastodon/Scene/Report/ReportViewModel+Provider.swift +++ /dev/null @@ -1,86 +0,0 @@ -//// -//// ReportViewModel+Provider.swift -//// Mastodon -//// -//// Created by ihugo on 2021/4/19. -//// -// -//import Combine -//import CoreData -//import CoreDataStack -//import Foundation -//import MastodonSDK -//import UIKit -//import os.log -// -//extension ReportViewController: StatusProvider { -// func status() -> Future { -// return Future { promise in promise(.success(nil)) } -// } -// -// func status(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { -// return Future { promise in -// guard let diffableDataSource = self.viewModel.diffableDataSource else { -// assertionFailure() -// promise(.success(nil)) -// return -// } -// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), -// let item = diffableDataSource.itemIdentifier(for: indexPath) else { -// promise(.success(nil)) -// return -// } -// -// switch item { -// case .status(let objectID, _): -// let managedObjectContext = self.viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext -// managedObjectContext.perform { -// let status = managedObjectContext.object(with: objectID) as? Status -// promise(.success(status)) -// } -// default: -// promise(.success(nil)) -// } -// } -// } -// -// func status(for cell: UICollectionViewCell) -> Future { -// return Future { promise in promise(.success(nil)) } -// } -// -// var managedObjectContext: NSManagedObjectContext { -// return viewModel.statusFetchedResultsController.fetchedResultsController.managedObjectContext -// } -// -// var tableViewDiffableDataSource: UITableViewDiffableDataSource? { -// return viewModel.diffableDataSource -// } -// -// func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? { -// guard let diffableDataSource = self.viewModel.diffableDataSource else { -// assertionFailure() -// return nil -// } -// -// guard let indexPath = indexPath ?? cell.flatMap({ self.tableView.indexPath(for: $0) }), -// let item = diffableDataSource.itemIdentifier(for: indexPath) else { -// return nil -// } -// -// return item -// } -// -// func items(indexPaths: [IndexPath]) -> [Item] { -// guard let diffableDataSource = self.viewModel.diffableDataSource else { -// assertionFailure() -// return [] -// } -// -// var items: [Item] = [] -// for indexPath in indexPaths { -// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { continue } -// items.append(item) -// } -// return items -// } -//} diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index 6feedc0b8..a7bba0a7e 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -26,8 +26,6 @@ class ReportViewModel: NSObject, NeedsDependency { weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } var userId: String var statusId: String? - var selectedItems = [Item]() - var comment: String? var reportQuery: FileReportQuery var disposeBag = Set() @@ -74,7 +72,7 @@ class ReportViewModel: NSObject, NeedsDependency { self.reportQuery = FileReportQuery( accountId: userId, - statusIds: nil, + statusIds: [], comment: nil, forward: nil ) @@ -90,8 +88,7 @@ class ReportViewModel: NSObject, NeedsDependency { setupDiffableDataSource( for: input.tableView, - dependency: self, - reportdStatusDelegate: self + dependency: self ) // data binding @@ -125,38 +122,31 @@ class ReportViewModel: NSObject, NeedsDependency { func bindData(input: Input) { input.didToggleSelected.sink { [weak self] (item) in guard let self = self else { return } - guard case let .status(objectID, attribute) = item else { return } + guard case let .reportStatus(objectID, attribute) = item else { return } guard var snapshot = self.diffableDataSource?.snapshot() else { return } let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext guard let status = managedObjectContext.object(with: objectID) as? Status else { return } - var items = [Item]() - if let index = self.selectedItems.firstIndex(of: item) { - self.selectedItems.remove(at: index) - items.append(.status(objectID: objectID, attribute: attribute)) - - if let index = self.reportQuery.statusIds?.firstIndex(of: status.id) { - self.reportQuery.statusIds?.remove(at: index) - } + attribute.isSelected = !attribute.isSelected + if attribute.isSelected { + self.reportQuery.append(statusId: status.id) } else { - self.selectedItems.append(item) - items.append(.status(objectID: objectID, attribute: attribute)) - self.reportQuery.statusIds?.append(status.id) + self.reportQuery.remove(statusId: status.id) } snapshot.reloadItems([item]) self.diffableDataSource?.apply(snapshot, animatingDifferences: false) - let continueEnable = self.selectedItems.count > 0 + let continueEnable = (self.reportQuery.statusIds?.count ?? 0) > 0 self.continueEnableSubject.send(continueEnable) } .store(in: &disposeBag) input.comment.assign( to: \.comment, - on: self + on: self.reportQuery ) .store(in: &disposeBag) input.comment.sink { [weak self] (comment) in @@ -170,7 +160,7 @@ class ReportViewModel: NSObject, NeedsDependency { func bindForStep1(input: Input) { let skip = input.step1Skip.map { [weak self] value -> Void in guard let self = self else { return value } - self.selectedItems.removeAll() + self.reportQuery.statusIds?.removeAll() return value } @@ -185,29 +175,13 @@ class ReportViewModel: NSObject, NeedsDependency { func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) { let skip = input.step2Skip.map { [weak self] value -> Void in guard let self = self else { return value } - self.comment = nil + self.reportQuery.comment = nil return value } Publishers.Merge(skip, input.step2Continue) .sink { [weak self] _ in guard let self = self else { return } - let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext - - self.reportQuery.comment = self.comment - - var selectedStatusIds = [String]() - self.selectedItems.forEach { (item) in - guard case .status(let objectId, _) = item else { - return - } - guard let status = managedObjectContext.object(with: objectId) as? Status else { - return - } - selectedStatusIds.append(status.id) - } - self.reportQuery.statusIds = selectedStatusIds - self.context.apiService.report( domain: domain, query: self.reportQuery, @@ -237,13 +211,3 @@ class ReportViewModel: NSObject, NeedsDependency { .store(in: &disposeBag) } } - -extension ReportViewModel: ReportedStatusTableViewCellDelegate { - func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool { - guard let item = diffableDataSource?.itemIdentifier(for: indexPath) else { - return false - } - - return selectedItems.contains(item) - } -} diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index a25c8b507..8329124cc 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -13,15 +13,10 @@ import CoreData import CoreDataStack import ActiveLabel -protocol ReportedStatusTableViewCellDelegate: class { - func reportedStatus(cell: ReportedStatusTableViewCell, isSelected indexPath: IndexPath) -> Bool -} - final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 - weak var delegate: ReportedStatusTableViewCellDelegate? var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift index a0afabb2b..17bcd5331 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -75,5 +75,19 @@ public extension Mastodon.API.Reports { self.comment = comment self.forward = forward } + + public func append(statusId: String) { + guard self.statusIds?.contains(statusId) != true else { return } + if self.statusIds == nil { + self.statusIds = [] + } + + self.statusIds?.append(statusId) + } + + public func remove(statusId: String) { + guard let index = self.statusIds?.firstIndex(of: statusId) else { return } + self.statusIds?.remove(at: index) + } } } From 61a26fbe66162bde496de42c50e44b34123a22c9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 23 Apr 2021 10:25:08 +0800 Subject: [PATCH 302/400] fix: refresh block state when view will appear --- .../Section/RecommendAccountSection.swift | 2 +- .../SuggestionAccountViewController.swift | 14 ++--- .../SuggestionAccountViewModel.swift | 59 ++++++++++++------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/Mastodon/Diffiable/Section/RecommendAccountSection.swift b/Mastodon/Diffiable/Section/RecommendAccountSection.swift index 92def9156..64019e580 100644 --- a/Mastodon/Diffiable/Section/RecommendAccountSection.swift +++ b/Mastodon/Diffiable/Section/RecommendAccountSection.swift @@ -40,7 +40,7 @@ extension RecommendAccountSection { guard let viewModel = viewModel else { return nil } let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SuggestionAccountTableViewCell.self)) as! SuggestionAccountTableViewCell let user = managedObjectContext.object(with: objectID) as! MastodonUser - let isSelected = viewModel.selectedAccounts.contains(objectID) + let isSelected = viewModel.selectedAccounts.value.contains(objectID) cell.delegate = delegate cell.config(with: user, isSelected: isSelected) return cell diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift index 017c1ee3d..80cd73cc1 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewController.swift @@ -110,8 +110,7 @@ extension SuggestionAccountViewController { super.viewWillLayoutSubviews() let avatarImageViewHeight: Double = 56 let avatarImageViewCount = Int(floor((Double(view.frame.width) - 20) / (avatarImageViewHeight + 15))) - viewModel.headerPlaceholderCount = avatarImageViewCount - viewModel.applySelectedCollectionViewDataSource(accounts: []) + viewModel.headerPlaceholderCount.value = avatarImageViewCount } func setupHeader(accounts: [NSManagedObjectID]) { @@ -179,7 +178,7 @@ extension SuggestionAccountViewController: UITableViewDelegate { extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegate { func accountButtonPressed(objectID: NSManagedObjectID, cell: SuggestionAccountTableViewCell) { - let selected = !viewModel.selectedAccounts.contains(objectID) + let selected = !viewModel.selectedAccounts.value.contains(objectID) cell.startAnimating() viewModel.followAction(objectID: objectID)? .sink(receiveCompletion: { [weak self] completion in @@ -189,13 +188,14 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat case .failure(let error): os_log("%{public}s[%{public}ld], %{public}s: follow failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) case .finished: + var selectedAccounts = self.viewModel.selectedAccounts.value if selected { - self.viewModel.selectedAccounts.append(objectID) + selectedAccounts.append(objectID) } else { - self.viewModel.selectedAccounts.removeAll { $0 == objectID } + selectedAccounts.removeAll { $0 == objectID } } cell.button.isSelected = selected - self.viewModel.selectedAccountsDidChange.send() + self.viewModel.selectedAccounts.value = selectedAccounts } }, receiveValue: { _ in }) @@ -206,7 +206,7 @@ extension SuggestionAccountViewController: SuggestionAccountTableViewCellDelegat extension SuggestionAccountViewController { @objc func doneButtonDidClick(_ sender: UIButton) { dismiss(animated: true, completion: nil) - if viewModel.selectedAccounts.count > 0 { + if viewModel.selectedAccounts.value.count > 0 { viewModel.delegate?.homeTimelineNeedRefresh.send() } } diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index 3a89d1431..d5ef6f6c7 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -27,11 +27,13 @@ final class SuggestionAccountViewModel: NSObject { weak var delegate: SuggestionAccountViewModelDelegate? // output let accounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) - var selectedAccounts = [NSManagedObjectID]() - let selectedAccountsDidChange = PassthroughSubject() - var headerPlaceholderCount: Int? + var selectedAccounts = CurrentValueSubject<[NSManagedObjectID], Never>([]) + + var headerPlaceholderCount = CurrentValueSubject(nil) var suggestionAccountsFallback = PassthroughSubject() + var viewWillAppear = PassthroughSubject() + var diffableDataSource: UITableViewDiffableDataSource? { didSet(value) { if !accounts.value.isEmpty { @@ -47,11 +49,22 @@ final class SuggestionAccountViewModel: NSObject { super.init() - self.accounts - .receive(on: DispatchQueue.main) - .sink { [weak self] accounts in + Publishers.CombineLatest(self.accounts,self.selectedAccounts) + .sink { [weak self] accounts,selectedAccounts in self?.applyTableViewDataSource(accounts: accounts) - self?.applySelectedCollectionViewDataSource(accounts: []) + self?.applySelectedCollectionViewDataSource(accounts: selectedAccounts) + } + .store(in: &disposeBag) + + Publishers.CombineLatest(self.selectedAccounts,self.headerPlaceholderCount) + .sink { [weak self] selectedAccount,count in + self?.applySelectedCollectionViewDataSource(accounts: selectedAccount) + } + .store(in: &disposeBag) + + viewWillAppear + .sink { [weak self] _ in + self?.checkAccountsFollowState() } .store(in: &disposeBag) @@ -59,14 +72,6 @@ final class SuggestionAccountViewModel: NSObject { self.accounts.value = accounts } - selectedAccountsDidChange - .sink { [weak self] _ in - guard let self = self else { return } - self.applyTableViewDataSource(accounts: self.accounts.value) - self.applySelectedCollectionViewDataSource(accounts: self.selectedAccounts) - } - .store(in: &disposeBag) - context.authenticationService.activeMastodonAuthentication .sink { [weak self] activeMastodonAuthentication in guard let self = self else { return } @@ -136,7 +141,7 @@ final class SuggestionAccountViewModel: NSObject { } func applySelectedCollectionViewDataSource(accounts: [NSManagedObjectID]) { - guard let count = headerPlaceholderCount else { return } + guard let count = headerPlaceholderCount.value else { return } guard let dataSource = collectionDiffableDataSource else { return } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -192,12 +197,26 @@ final class SuggestionAccountViewModel: NSObject { guard let currentMastodonUser = currentMastodonUser.value else { return } - let users = accounts.value.compactMap { context.managedObjectContext.object(with: $0) as? MastodonUser } + let users: [MastodonUser] = accounts.value.compactMap { + guard let user = context.managedObjectContext.object(with: $0) as? MastodonUser else { + return nil + } + let isBlock = user.blockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + let isDomainBlock = user.domainBlockingBy.flatMap { $0.contains(currentMastodonUser) } ?? false + if isBlock || isDomainBlock { + return nil + } else { + return user + } + } + accounts.value = users.map(\.objectID) + let followingUsers = users.filter { user -> Bool in let isFollowing = user.followingBy.flatMap { $0.contains(currentMastodonUser) } ?? false - return isFollowing + let isPending = user.followRequestedBy.flatMap { $0.contains(currentMastodonUser) } ?? false + return isFollowing || isPending }.map(\.objectID) - selectedAccounts = followingUsers - selectedAccountsDidChange.send() + + selectedAccounts.value = followingUsers } } From d6d91180cbf8ed80657c9a91c25501da11d06241 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 23 Apr 2021 19:15:52 +0800 Subject: [PATCH 303/400] fix: the data source update queue , change activityIndicatorView color --- Mastodon/Scene/Search/SearchViewModel.swift | 14 +++++++------- .../SuggestionAccountTableViewCell.swift | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/Mastodon/Scene/Search/SearchViewModel.swift b/Mastodon/Scene/Search/SearchViewModel.swift index 5f1ff5f46..e10b04c9e 100644 --- a/Mastodon/Scene/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/SearchViewModel.swift @@ -162,7 +162,6 @@ final class SearchViewModel: NSObject { .store(in: &disposeBag) requestRecommendAccountsV2() - .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { @@ -176,7 +175,6 @@ final class SearchViewModel: NSObject { .sink { [weak self] _ in guard let self = self else { return } self.requestRecommendAccounts() - .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } if !self.recommendAccounts.isEmpty { @@ -295,11 +293,13 @@ final class SearchViewModel: NSObject { } func applyDataSource() { - guard let dataSource = accountDiffableDataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(recommendAccounts, toSection: .main) - dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + DispatchQueue.main.async { + guard let dataSource = self.accountDiffableDataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(self.recommendAccounts, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false, completion: nil) + } } func receiveAccounts(ids: [String]) { diff --git a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift index a550fd889..db56d63ca 100644 --- a/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift +++ b/Mastodon/Scene/SuggestionAccount/TableViewCell/SuggestionAccountTableViewCell.swift @@ -62,7 +62,6 @@ final class SuggestionAccountTableViewCell: UITableViewCell { let activityIndicatorView: UIActivityIndicatorView = { let activityIndicatorView = UIActivityIndicatorView(style: .medium) - activityIndicatorView.color = .white activityIndicatorView.hidesWhenStopped = true return activityIndicatorView }() From 490e168b7d8699e80f7f33f7c661468682f5617a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 24 Apr 2021 19:50:54 -0700 Subject: [PATCH 304/400] fix: UI update in confirm email scene --- Mastodon/Generated/Assets.swift | 1 + .../Asset/email.imageset/Contents.json | 12 + .../Asset/email.imageset/c1.svg | 342 ++++++++++++++++++ .../MastodonConfirmEmailViewController.swift | 37 +- 4 files changed, 382 insertions(+), 10 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 35111dde1..d26424c75 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -23,6 +23,7 @@ internal typealias AssetImageTypeAlias = ImageAsset.Image internal enum Asset { internal static let accentColor = ColorAsset(name: "AccentColor") internal enum Asset { + internal static let email = ImageAsset(name: "Asset/email") internal static let mastodonTextLogo = ImageAsset(name: "Asset/mastodon.text.logo") } internal enum Circles { diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json new file mode 100644 index 000000000..1b7212647 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "c1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg new file mode 100644 index 000000000..ce59284c0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 9d15c8476..80a4eed15 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -36,6 +36,13 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc label.numberOfLines = 0 return label }() + + let emailImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = Asset.Asset.email.image + imageView.contentMode = .scaleAspectFit + return imageView + }() let openEmailButton: UIButton = { let button = UIButton(type: .system) @@ -66,19 +73,10 @@ final class MastodonConfirmEmailViewController: UIViewController, NeedsDependenc } extension MastodonConfirmEmailViewController { - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } override func viewDidLoad() { setupOnboardingAppearance() - - // resizedView - let resizedView = UIView() - resizedView.translatesAutoresizingMaskIntoConstraints = false - resizedView.setContentHuggingPriority(.defaultLow, for: .vertical) // stackView let stackView = UIStackView() @@ -89,7 +87,9 @@ extension MastodonConfirmEmailViewController { stackView.isLayoutMarginsRelativeArrangement = true stackView.addArrangedSubview(self.largeTitleLabel) stackView.addArrangedSubview(self.subtitleLabel) - stackView.addArrangedSubview(resizedView) + stackView.addArrangedSubview(self.emailImageView) + emailImageView.setContentHuggingPriority(.defaultLow, for: .vertical) + emailImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) stackView.addArrangedSubview(self.openEmailButton) stackView.addArrangedSubview(self.dontReceiveButton) @@ -200,3 +200,20 @@ extension MastodonConfirmEmailViewController { // MARK: - OnboardingViewControllerAppearance extension MastodonConfirmEmailViewController: OnboardingViewControllerAppearance { } + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct MastodonConfirmEmailViewController_Previews: PreviewProvider { + + static var previews: some View { + UIViewControllerPreview { + let viewController = MastodonConfirmEmailViewController() + return viewController + } + .previewLayout(.fixed(width: 375, height: 800)) + } + +} + +#endif From 2bf40b3d09ef4871b3420e8db6b0f76cc7830a30 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 24 Apr 2021 20:10:00 -0700 Subject: [PATCH 305/400] fix: pick scene UI layout issue --- .../PickServer/MastodonPickServerViewController.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 241a597c1..39ed714d7 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -22,7 +22,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency private var expandServerDomainSet = Set() private let emptyStateView = PickServerEmptyStateView() - private let emptyStateViewHPadding: CGFloat = 4 // UIView's readableContentGuide is 4pt smaller then UITableViewCell's let tableViewTopPaddingView = UIView() // fix empty state view background display when tableView bounce scrolling var tableViewTopPaddingViewHeightLayoutConstraint: NSLayoutConstraint! @@ -90,8 +89,8 @@ extension MastodonPickServerViewController { view.addSubview(emptyStateView) NSLayoutConstraint.activate([ emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: emptyStateViewHPadding), - emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor, constant: -emptyStateViewHPadding), + emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), ]) From e3001c772aa778744c959230742b4a19272d8639 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 24 Apr 2021 20:39:21 -0700 Subject: [PATCH 306/400] fix: Handle keyboard return button in register scene --- .../MastodonRegisterViewController.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 2187ad52b..009e26536 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -360,6 +360,14 @@ extension MastodonRegisterViewController { // password stackView.setCustomSpacing(6, after: passwordTextField) stackView.setCustomSpacing(32, after: passwordCheckLabel) + + //return + if viewModel.approvalRequired { + passwordTextField.returnKeyType = .continue + } else { + passwordTextField.returnKeyType = .done + } + reasonTextField.returnKeyType = .done // button stackView.addArrangedSubview(buttonContainer) @@ -619,6 +627,28 @@ extension MastodonRegisterViewController: UITextFieldDelegate { } } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + switch textField { + case usernameTextField: + displayNameTextField.becomeFirstResponder() + case displayNameTextField: + emailTextField.becomeFirstResponder() + case emailTextField: + passwordTextField.becomeFirstResponder() + case passwordTextField: + if viewModel.approvalRequired { + reasonTextField.becomeFirstResponder() + } else { + passwordTextField.resignFirstResponder() + } + case reasonTextField: + reasonTextField.resignFirstResponder() + default: + break + } + return true + } + func showShadowWithColor(color: UIColor, textField: UITextField) { // To apply Shadow textField.layer.shadowOpacity = 1 From 9001289801aef99492c7d7c84281480dd9a075b0 Mon Sep 17 00:00:00 2001 From: CMK Date: Sun, 25 Apr 2021 12:48:29 +0800 Subject: [PATCH 307/400] feat: add push notification --- Mastodon.xcodeproj/project.pbxproj | 241 +++++++++++++++++- .../xcschemes/xcschememanagement.plist | 13 +- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Extension/Array+removeDuplicates.swift | 23 -- Mastodon/Extension/Array.swift | 99 +++++++ Mastodon/Mastodon.entitlements | 2 + .../Settings/SettingsViewController.swift | 14 +- .../Scene/Settings/SettingsViewModel.swift | 101 ++++---- .../APIService/APIService+Subscriptions.swift | 17 +- Mastodon/Service/NotificationService.swift | 241 ++++++++++++++++++ Mastodon/State/AppContext.swift | 8 +- Mastodon/Supporting Files/AppDelegate.swift | 16 +- Mastodon/Supporting Files/AppSecret.swift | 51 ++++ .../MastodonSDK/API/Mastodon+API+Push.swift | 149 +++++------ .../Sources/MastodonSDK/Extension/Data.swift | 9 + NotificationService/Extension/String.swift | 31 +++ NotificationService/Info.plist | 31 +++ .../NotificationService+Decrypt.swift | 96 +++++++ NotificationService/NotificationService.swift | 103 ++++++++ Podfile | 16 ++ Podfile.lock | 9 +- README.md | 1 + 22 files changed, 1094 insertions(+), 186 deletions(-) delete mode 100644 Mastodon/Extension/Array+removeDuplicates.swift create mode 100644 Mastodon/Extension/Array.swift create mode 100644 Mastodon/Service/NotificationService.swift create mode 100644 Mastodon/Supporting Files/AppSecret.swift create mode 100644 NotificationService/Extension/String.swift create mode 100644 NotificationService/Info.plist create mode 100644 NotificationService/NotificationService+Decrypt.swift create mode 100644 NotificationService/NotificationService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0f8236e62..30202e558 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; + 0F20223926146553000C64BF /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */; }; 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */; }; 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FAA101B25E10E760017CCDE /* UIFont.swift */; }; @@ -160,7 +160,9 @@ 5DFC35DF262068D20045711D /* SearchViewController+Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */; }; 5E0DEC05797A7E6933788DDB /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */; }; 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */; }; @@ -173,6 +175,7 @@ DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; + DB1E05E1263180F500201847 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; @@ -220,6 +223,7 @@ DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */; }; @@ -246,6 +250,10 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; + DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -367,6 +375,10 @@ DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; }; + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -405,6 +417,13 @@ remoteGlobalIDString = DB89B9ED25C10FD0008580ED; remoteInfo = CoreDataStack; }; + DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DBF8AE12263293E400C9C23C; + remoteInfo = NotificationService; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -420,6 +439,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE1B263293E400C9C23C /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -432,7 +462,7 @@ 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; + 0F20223826146553000C64BF /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 0FAA0FDE25E0B57E0017CCDE /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; 0FAA101125E105390017CCDE /* PrimaryActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryActionButton.swift; sourceTree = ""; }; 0FAA101B25E10E760017CCDE /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; @@ -574,8 +604,11 @@ 5DF1058425F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NeedsDependency+AVPlayerViewControllerDelegate.swift"; sourceTree = ""; }; 5DFC35DE262068D20045711D /* SearchViewController+Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchViewController+Follow.swift"; sourceTree = ""; }; 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon.release.xcconfig"; path = "Target Support Files/Pods-Mastodon/Pods-Mastodon.release.xcconfig"; sourceTree = ""; }; + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_NotificationService.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; @@ -589,6 +622,7 @@ DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; + DB1E05E0263180F500201847 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; @@ -643,6 +677,7 @@ DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; + DB4924E126312AB200E9DB22 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; DB49A62425FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel+LoadState.swift"; sourceTree = ""; }; @@ -668,6 +703,8 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -790,6 +827,10 @@ DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; + DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DBF8AE15263293E400C9C23C /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + DBF8AE17263293E400C9C23C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.debug.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -806,6 +847,7 @@ DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, + DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, @@ -846,6 +888,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE10263293E400C9C23C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, + DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, + DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -924,6 +977,8 @@ BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */, EC6E707B68A67DB08EC288FA /* Pods-MastodonTests.debug.xcconfig */, 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, + 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, + B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -1053,6 +1108,7 @@ 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, + DB4924E126312AB200E9DB22 /* NotificationService.swift */, ); path = Service; sourceTree = ""; @@ -1216,9 +1272,11 @@ 3FE14AD363ED19AE7FF210A6 /* Frameworks */ = { isa = PBXGroup; children = ( + DBF96325262EC0A6001D8D25 /* AuthenticationServices.framework */, A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */, 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, + 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, ); name = Frameworks; sourceTree = ""; @@ -1318,6 +1376,7 @@ children = ( DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, + DB1E05E0263180F500201847 /* AppSecret.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1347,6 +1406,7 @@ DB427DF625BAA00100D1B89D /* MastodonUITests */, DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, + DBF8AE14263293E400C9C23C /* NotificationService */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1362,6 +1422,7 @@ DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */, DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, + DBF8AE13263293E400C9C23C /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -1516,6 +1577,14 @@ path = MastodonSDK; sourceTree = ""; }; + DB6D9F2926351961008423CD /* Extension */ = { + isa = PBXGroup; + children = ( + DB6D9F222635195E008423CD /* String.swift */, + ); + path = Extension; + sourceTree = ""; + }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -1715,7 +1784,7 @@ 2D3F9E0325DFA133004262D9 /* UITapGestureRecognizer.swift */, 2D84350425FF858100EECE90 /* UIScrollView.swift */, DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */, - 0F20223826146553000C64BF /* Array+removeDuplicates.swift */, + 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, ); @@ -1951,6 +2020,17 @@ path = Favorite; sourceTree = ""; }; + DBF8AE14263293E400C9C23C /* NotificationService */ = { + isa = PBXGroup; + children = ( + DBF8AE15263293E400C9C23C /* NotificationService.swift */, + DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, + DB6D9F2926351961008423CD /* Extension */, + DBF8AE17263293E400C9C23C /* Info.plist */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1976,11 +2056,13 @@ DB427DD025BAA00100D1B89D /* Resources */, 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */, DB89BA0825C10FD0008580ED /* Embed Frameworks */, + DBF8AE1B263293E400C9C23C /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, + DBF8AE19263293E400C9C23C /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -2076,6 +2158,29 @@ productReference = DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + DBF8AE12263293E400C9C23C /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */, + DBF8AE0F263293E400C9C23C /* Sources */, + DBF8AE10263293E400C9C23C /* Frameworks */, + DBF8AE11263293E400C9C23C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + DBF8AE852632992800C9C23C /* Base85 */, + DB00CA962632DDB600A54956 /* CommonOSLog */, + DB6D9F41263527CE008423CD /* AlamofireImage */, + ); + productName = NotificationService; + productReference = DBF8AE13263293E400C9C23C /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -2105,6 +2210,9 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DBF8AE12263293E400C9C23C = { + CreatedOnToolsVersion = 12.4; + }; }; }; buildConfigurationList = DB427DCD25BAA00100D1B89D /* Build configuration list for PBXProject "Mastodon" */; @@ -2127,6 +2235,7 @@ DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2137,6 +2246,7 @@ DB427DF225BAA00100D1B89D /* MastodonUITests */, DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, + DBF8AE12263293E400C9C23C /* NotificationService */, ); }; /* End PBXProject section */ @@ -2184,9 +2294,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE11263293E400C9C23C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0DC740704503CA6BED56F5C8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-NotificationService-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 5532CB85BBE168B25B20720B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2551,7 +2690,7 @@ DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */, 2D32EADA25CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift in Sources */, 5B90C48526259BF10002E742 /* APIService+Subscriptions.swift in Sources */, - 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */, + 0F20223926146553000C64BF /* Array.swift in Sources */, 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */, DB8AF54425C13647002E6C99 /* SceneCoordinator.swift in Sources */, 5DF1058525F88AE500D6C0D4 /* NeedsDependency+AVPlayerViewControllerDelegate.swift in Sources */, @@ -2594,6 +2733,7 @@ DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, + DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, @@ -2609,6 +2749,7 @@ 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, + DB1E05E1263180F500201847 /* AppSecret.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, @@ -2675,6 +2816,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DBF8AE0F263293E400C9C23C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6D9F232635195E008423CD /* String.swift in Sources */, + DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, + DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2703,6 +2855,11 @@ target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; targetProxy = DB89BA0125C10FD0008580ED /* PBXContainerItemProxy */; }; + DBF8AE19263293E400C9C23C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DBF8AE12263293E400C9C23C /* NotificationService */; + targetProxy = DBF8AE18263293E400C9C23C /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2861,6 +3018,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -2888,6 +3046,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */; buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3089,6 +3248,48 @@ }; name = Release; }; + DBF8AE1C263293E400C9C23C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DBF8AE1D263293E400C9C23C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 7LFDZ96332; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.NotificationService; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -3146,6 +3347,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DBF8AE1E263293E400C9C23C /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DBF8AE1C263293E400C9C23C /* Debug */, + DBF8AE1D263293E400C9C23C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -3229,6 +3439,14 @@ kind = branch; }; }; + DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/MainasuK/Base85.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3256,6 +3474,11 @@ isa = XCSwiftPackageProductDependency; productName = MastodonSDK; }; + DB00CA962632DDB600A54956 /* CommonOSLog */ = { + isa = XCSwiftPackageProductDependency; + package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; + productName = CommonOSLog; + }; DB0140BC25C40D7500F9F3CF /* CommonOSLog */ = { isa = XCSwiftPackageProductDependency; package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; @@ -3271,6 +3494,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB6D9F41263527CE008423CD /* AlamofireImage */ = { + isa = XCSwiftPackageProductDependency; + package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; + productName = AlamofireImage; + }; DB9A487D2603456B008B817C /* UITextView+Placeholder */ = { isa = XCSwiftPackageProductDependency; package = DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */; @@ -3286,6 +3514,11 @@ package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; productName = TwitterTextEditor; }; + DBF8AE852632992800C9C23C /* Base85 */ = { + isa = XCSwiftPackageProductDependency; + package = DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */; + productName = Base85; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 18c8840d8..41711ac91 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,22 +7,27 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 14 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 9 + 2 Mastodon - Release.xcscheme_^#shared#^_ orderHint - 2 + 0 Mastodon.xcscheme_^#shared#^_ orderHint - 7 + 1 + + NotificationService.xcscheme_^#shared#^_ + + orderHint + 17 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..a8c0df347 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -37,6 +37,15 @@ "version": "3.1.0" } }, + { + "package": "Base85", + "repositoryURL": "https://github.com/MainasuK/Base85.git", + "state": { + "branch": null, + "revision": "626be96816618689627f806b5c875b5adb6346ef", + "version": "1.0.1" + } + }, { "package": "CommonOSLog", "repositoryURL": "https://github.com/MainasuK/CommonOSLog", diff --git a/Mastodon/Extension/Array+removeDuplicates.swift b/Mastodon/Extension/Array+removeDuplicates.swift deleted file mode 100644 index c3a4b0384..000000000 --- a/Mastodon/Extension/Array+removeDuplicates.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Array+removeDuplicates.swift -// Mastodon -// -// Created by BradGao on 2021/3/31. -// - -import Foundation - -/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array -extension Array where Element: Hashable { - func removingDuplicates() -> [Element] { - var addedDict = [Element: Bool]() - - return filter { - addedDict.updateValue(true, forKey: $0) == nil - } - } - - mutating func removeDuplicates() { - self = self.removingDuplicates() - } -} diff --git a/Mastodon/Extension/Array.swift b/Mastodon/Extension/Array.swift new file mode 100644 index 000000000..42f8594d1 --- /dev/null +++ b/Mastodon/Extension/Array.swift @@ -0,0 +1,99 @@ +// +// Array.swift +// Mastodon +// +// Created by BradGao on 2021/3/31. +// + +import Foundation + +/// https://www.hackingwithswift.com/example-code/language/how-to-remove-duplicate-items-from-an-array +extension Array where Element: Hashable { + func removingDuplicates() -> [Element] { + var addedDict = [Element: Bool]() + + return filter { + addedDict.updateValue(true, forKey: $0) == nil + } + } + + mutating func removeDuplicates() { + self = self.removingDuplicates() + } +} + +// +// CryptoSwift +// +// Copyright (C) 2014-2017 Marcin Krzyżanowski +// This software is provided 'as-is', without any express or implied warranty. +// +// In no event will the authors be held liable for any damages arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose,including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: +// +// - The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation is required. +// - Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. +// - This notice may not be removed or altered from any source or binary distribution. +// + +extension Array { + init(reserveCapacity: Int) { + self = Array() + self.reserveCapacity(reserveCapacity) + } + + var slice: ArraySlice { + self[self.startIndex ..< self.endIndex] + } +} + +extension Array where Element == UInt8 { + public init(hex: String) { + self.init(reserveCapacity: hex.unicodeScalars.lazy.underestimatedCount) + var buffer: UInt8? + var skip = hex.hasPrefix("0x") ? 2 : 0 + for char in hex.unicodeScalars.lazy { + guard skip == 0 else { + skip -= 1 + continue + } + guard char.value >= 48 && char.value <= 102 else { + removeAll() + return + } + let v: UInt8 + let c: UInt8 = UInt8(char.value) + switch c { + case let c where c <= 57: + v = c - 48 + case let c where c >= 65 && c <= 70: + v = c - 55 + case let c where c >= 97: + v = c - 87 + default: + removeAll() + return + } + if let b = buffer { + append(b << 4 | v) + buffer = nil + } else { + buffer = v + } + } + if let b = buffer { + append(b) + } + } + + public func toHexString() -> String { + `lazy`.reduce(into: "") { + var s = String($1, radix: 16) + if s.count == 1 { + s = "0" + s + } + $0 += s + } + } +} diff --git a/Mastodon/Mastodon.entitlements b/Mastodon/Mastodon.entitlements index d334a5e6d..8917adbf4 100644 --- a/Mastodon/Mastodon.entitlements +++ b/Mastodon/Mastodon.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.org.joinmastodon.mastodon-temp diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 4615f92ab..b9ded67d0 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -55,7 +55,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true - view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) + //view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) view.axis = .horizontal view.alignment = .fill view.distribution = .equalSpacing @@ -270,8 +270,9 @@ extension SettingsViewController: UITableViewDelegate { guard section < sections.count else { return nil } let sectionData = sections[section] + let header: SettingsSectionHeader if section == 1 { - let header = SettingsSectionHeader( + header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) header.update(title: sectionData.title) @@ -282,16 +283,19 @@ extension SettingsViewController: UITableViewDelegate { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone whoButton.setTitle(anyone, for: .normal) } - return header } else { - let header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) + header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) header.update(title: sectionData.title) - return header } + + header.preservesSuperviewLayoutMargins = true + + return header } // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView() } diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index 470617aeb..f7ee4c71b 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -96,49 +96,49 @@ class SettingsViewModel: NSObject, NeedsDependency { func transform(input: Input?) -> Output? { typealias SubscriptionResponse = Mastodon.Response.Content - createSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.createDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - // FIXME: to replace the correct endpoint, p256dh, auth - endpoint: "http://www.google.com", - p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", - auth: "4vQK-SvRAN5eo-8ASlrwA==", - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil - ) - self.context.apiService.changeSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.createDisposeBag) - } - .store(in: &disposeBag) +// createSubscriptionSubject +// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) +// .sink { _ in +// } receiveValue: { [weak self] (arg) in +// let (triggerBy, values) = arg +// guard let self = self else { +// return +// } +// guard let activeMastodonAuthenticationBox = +// self.context.authenticationService.activeMastodonAuthenticationBox.value else { +// return +// } +// guard values.count >= 4 else { +// return +// } +// +// self.createDisposeBag.removeAll() +// typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery +// let domain = activeMastodonAuthenticationBox.domain +// let query = Query( +// // FIXME: to replace the correct endpoint, p256dh, auth +// endpoint: "http://www.google.com", +// p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", +// auth: "4vQK-SvRAN5eo-8ASlrwA==", +// favourite: values[0], +// follow: values[1], +// reblog: values[2], +// mention: values[3], +// poll: nil +// ) +// self.context.apiService.changeSubscription( +// domain: domain, +// mastodonAuthenticationBox: activeMastodonAuthenticationBox, +// query: query, +// triggerBy: triggerBy, +// userID: activeMastodonAuthenticationBox.userID +// ) +// .sink { (_) in +// } receiveValue: { (_) in +// } +// .store(in: &self.createDisposeBag) +// } +// .store(in: &disposeBag) updateSubscriptionSubject .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) @@ -160,11 +160,16 @@ class SettingsViewModel: NSObject, NeedsDependency { typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery let domain = activeMastodonAuthenticationBox.domain let query = Query( - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil) + data: Mastodon.API.Subscriptions.QueryData( + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: values[0], + follow: values[1], + reblog: values[2], + mention: values[3], + poll: nil + ) + ) + ) self.context.apiService.updateSubscription( domain: domain, mastodonAuthenticationBox: activeMastodonAuthenticationBox, diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 337ab26d2..5260452ce 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -14,11 +14,11 @@ import MastodonSDK extension APIService { func subscription( - domain: String, - userID: String, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID let findSettings: Setting? = { let request = Setting.sortedFetchRequest @@ -58,18 +58,21 @@ extension APIService { }.eraseToAnyPublisher() } - func changeSubscription( - domain: String, + func createSubscription( mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, triggerBy: String, userID: String ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) + let setting = self.createSettingIfNeed( + domain: domain, + userId: userID, + triggerBy: triggerBy + ) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift new file mode 100644 index 000000000..098fdff62 --- /dev/null +++ b/Mastodon/Service/NotificationService.swift @@ -0,0 +1,241 @@ +// +// NotificationService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class NotificationService { + + var disposeBag = Set() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + let isNotificationPermissionGranted = CurrentValueSubject(false) + let deviceToken = CurrentValueSubject(nil) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) + + // output + /// [Token: UserID] + let notificationSubscriptionDict: [String: NotificationSubscription] = [:] + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + + authenticationService.mastodonAuthentications + .handleEvents(receiveOutput: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } + } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + deviceToken + .receive(on: DispatchQueue.main) + .sink { [weak self] deviceToken in + guard let self = self else { return } + guard let deviceToken = deviceToken else { return } + let token = [UInt8](deviceToken).toHexString() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) + } + .store(in: &disposeBag) + + Publishers.CombineLatest3( + isNotificationPermissionGranted, + deviceToken, + mastodonAuthenticationBoxes + ) + .sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in + guard let self = self else { return } + guard isNotificationPermissionGranted else { return } + guard let deviceToken = deviceToken else { return } + self.registerNotificationSubscriptions( + deviceToken: [UInt8](deviceToken).toHexString(), + mastodonAuthenticationBoxes: mastodonAuthenticationBoxes + ) + } + .store(in: &disposeBag) + } + +} + +extension NotificationService { + private func requestNotificationPermission() { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] granted, error in + guard let self = self else { return } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: request notification permission: %s", ((#file as NSString).lastPathComponent), #line, #function, granted ? "granted" : "fail") + + self.isNotificationPermissionGranted.value = granted + + if let _ = error { + // Handle the error here. + } + + // Enable or disable features based on the authorization. + } + } + + private func registerNotificationSubscriptions( + deviceToken: String, + mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox] + ) { + for mastodonAuthenticationBox in mastodonAuthenticationBoxes { + guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue } + let token = NotificationSubscription.SubscribeToken( + deviceToken: deviceToken, + authenticationBox: mastodonAuthenticationBox + ) + guard let subscription = subscribe( + notificationSubscription: notificationSubscription, + token: token + ) else { continue } + + subscription + .sink { completion in + // handle error + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken) + // do nothing + } + .store(in: &self.disposeBag) + } + } + + private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? { + var _notificationSubscription: NotificationSubscription? + workingQueue.sync { + let domain = mastodonAuthenticationBox.domain + let userID = mastodonAuthenticationBox.userID + let key = [domain, userID].joined(separator: "@") + + if let notificationSubscription = notificationSubscriptionDict[key] { + _notificationSubscription = notificationSubscription + } else { + let notificationSubscription = NotificationSubscription(domain: domain, userID: userID) + _notificationSubscription = notificationSubscription + } + } + return _notificationSubscription + } + + private func subscribe( + notificationSubscription: NotificationSubscription, + token: NotificationSubscription.SubscribeToken + ) -> AnyPublisher, Error>? { + guard let apiService = self.apiService else { return nil } + + if let oldToken = notificationSubscription.token { + guard oldToken != token else { return nil } + } + notificationSubscription.token = token + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken + let p256dh = appSecret.uncompressionNotificationPublicKeyData + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: Mastodon.API.Subscriptions.QueryData( + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: true, + follow: true, + reblog: true, + mention: true, + poll: true + ) + ) + ) + + return apiService.createSubscription( + mastodonAuthenticationBox: token.authenticationBox, + query: query, + triggerBy: "anyone", + userID: token.authenticationBox.userID + ) + } + + static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } +} + +extension NotificationService { + final class NotificationSubscription { + + var disposeBag = Set() + + // input + let domain: String + let userID: Mastodon.Entity.Account.ID + + var token: SubscribeToken? + + init(domain: String, userID: Mastodon.Entity.Account.ID) { + self.domain = domain + self.userID = userID + } + + struct SubscribeToken: Equatable { + + let deviceToken: String + let authenticationBox: AuthenticationService.MastodonAuthenticationBox + // TODO: set other parameter + + init( + deviceToken: String, + authenticationBox: AuthenticationService.MastodonAuthenticationBox + ) { + self.deviceToken = deviceToken + self.authenticationBox = authenticationBox + } + + static func == ( + lhs: NotificationService.NotificationSubscription.SubscribeToken, + rhs: NotificationService.NotificationSubscription.SubscribeToken + ) -> Bool { + return lhs.deviceToken == rhs.deviceToken && + lhs.authenticationBox.domain == rhs.authenticationBox.domain && + lhs.authenticationBox.userID == rhs.authenticationBox.userID && + lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken + } + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 903cb7693..1ed2c283f 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -28,6 +28,7 @@ class AppContext: ObservableObject { let videoPlaybackService = VideoPlaybackService() let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() + let notificationService: NotificationService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -45,11 +46,12 @@ class AppContext: ObservableObject { let _apiService = APIService(backgroundManagedObjectContext: _backgroundManagedObjectContext) apiService = _apiService - authenticationService = AuthenticationService( + let _authenticationService = AuthenticationService( managedObjectContext: _managedObjectContext, backgroundManagedObjectContext: _backgroundManagedObjectContext, apiService: _apiService ) + authenticationService = _authenticationService emojiService = EmojiService( apiService: apiService @@ -57,6 +59,10 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) + notificationService = NotificationService( + apiService: _apiService, + authenticationService: _authenticationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 72ee1334d..fd4c23004 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021/1/22. // +import os.log import UIKit @main @@ -18,6 +19,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") +// UNUserNotificationCenter.current().delegate = self + application.registerForRemoteNotifications() + return true } @@ -38,13 +42,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } - extension AppDelegate { func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return UIDevice.current.userInterfaceIdiom == .phone ? .portrait : .all } } +extension AppDelegate { + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + appContext.notificationService.deviceToken.value = deviceToken + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension AppDelegate: UNUserNotificationCenterDelegate { + +} extension AppContext { static var shared: AppContext { diff --git a/Mastodon/Supporting Files/AppSecret.swift b/Mastodon/Supporting Files/AppSecret.swift new file mode 100644 index 000000000..0a30553a7 --- /dev/null +++ b/Mastodon/Supporting Files/AppSecret.swift @@ -0,0 +1,51 @@ +// +// AppSecret.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-22. +// + +import Foundation +import CryptoKit +import Keys + +final class AppSecret { + + let notificationEndpoint: String + + let notificationPrivateKey: P256.KeyAgreement.PrivateKey! + let notificationPublicKey: P256.KeyAgreement.PublicKey! + let notificationAuth: Data + + static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + let nonce = keys.notification_key_nonce_debug + let auth = keys.notification_key_auth_debug + #else + self.notificationEndpoint = keys.notification_endpoint + let nonce = keys.notification_key_nonce + let auth = keys.notification_key_auth + #endif + + notificationPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: nonce)!) + notificationPublicKey = notificationPrivateKey!.publicKey + notificationAuth = Data(base64Encoded: auth)! + } + + var uncompressionNotificationPublicKeyData: Data { + var data = notificationPublicKey.rawRepresentation + if data.count == 64 { + let prefix: [UInt8] = [0x04] + data = Data(prefix) + data + } + return data + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index df9168499..f78c2c6c6 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -117,110 +117,75 @@ extension Mastodon.API.Subscriptions { } extension Mastodon.API.Subscriptions { - public struct CreateSubscriptionQuery: Codable, PostQuery { + + public struct QuerySubscription: Codable { let endpoint: String - let p256dh: String - let auth: String - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - items.append(URLQueryItem(name: "subscription[endpoint]", value: endpoint)) - items.append(URLQueryItem(name: "subscription[keys][p256dh]", value: p256dh)) - items.append(URLQueryItem(name: "subscription[keys][auth]", value: auth)) - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items - } + let keys: Keys public init( endpoint: String, - p256dh: String, - auth: String, - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? + keys: Keys ) { self.endpoint = endpoint - self.p256dh = p256dh - self.auth = auth - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll + self.keys = keys + } + + public struct Keys: Codable { + let p256dh: String + let auth: String + + public init(p256dh: Data, auth: Data) { + self.p256dh = p256dh.base64UrlEncodedString() + self.auth = auth.base64UrlEncodedString() + } + } + } + + public struct QueryData: Codable { + let alerts: Alerts + + public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) { + self.alerts = alerts + } + + public struct Alerts: Codable { + let favourite: Bool? + let follow: Bool? + let reblog: Bool? + let mention: Bool? + let poll: Bool? + + public init(favourite: Bool?, follow: Bool?, reblog: Bool?, mention: Bool?, poll: Bool?) { + self.favourite = favourite + self.follow = follow + self.reblog = reblog + self.mention = mention + self.poll = poll + } + } + } + + public struct CreateSubscriptionQuery: Codable, PostQuery { + let subscription: QuerySubscription + let data: QueryData + + public init( + subscription: Mastodon.API.Subscriptions.QuerySubscription, + data: Mastodon.API.Subscriptions.QueryData + ) { + self.subscription = subscription + self.data = data } } public struct UpdateSubscriptionQuery: Codable, PutQuery { - let favourite: Bool? - let follow: Bool? - let reblog: Bool? - let mention: Bool? - let poll: Bool? - var queryItems: [URLQueryItem]? { - var items = [URLQueryItem]() - - if let followValue = follow?.queryItemValue { - let followItem = URLQueryItem(name: "data[alerts][follow]", value: followValue) - items.append(followItem) - } - - if let favouriteValue = favourite?.queryItemValue { - let favouriteItem = URLQueryItem(name: "data[alerts][favourite]", value: favouriteValue) - items.append(favouriteItem) - } - - if let reblogValue = reblog?.queryItemValue { - let reblogItem = URLQueryItem(name: "data[alerts][reblog]", value: reblogValue) - items.append(reblogItem) - } - - if let mentionValue = mention?.queryItemValue { - let mentionItem = URLQueryItem(name: "data[alerts][mention]", value: mentionValue) - items.append(mentionItem) - } - return items + let data: QueryData + + public init(data: Mastodon.API.Subscriptions.QueryData) { + self.data = data } - public init( - favourite: Bool?, - follow: Bool?, - reblog: Bool?, - mention: Bool?, - poll: Bool? - ) { - self.favourite = favourite - self.follow = follow - self.reblog = reblog - self.mention = mention - self.poll = poll - } + var queryItems: [URLQueryItem]? { nil } } } diff --git a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift index 43354394d..48e442b9d 100644 --- a/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift +++ b/MastodonSDK/Sources/MastodonSDK/Extension/Data.swift @@ -35,3 +35,12 @@ extension Data { } } + +extension Data { + func base64UrlEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/NotificationService/Extension/String.swift b/NotificationService/Extension/String.swift new file mode 100644 index 000000000..edb162428 --- /dev/null +++ b/NotificationService/Extension/String.swift @@ -0,0 +1,31 @@ +// +// String.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} + diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist new file mode 100644 index 000000000..7db9ec9cb --- /dev/null +++ b/NotificationService/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift new file mode 100644 index 000000000..065863fda --- /dev/null +++ b/NotificationService/NotificationService+Decrypt.swift @@ -0,0 +1,96 @@ +// +// NotificationService+Decrypt.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import Foundation +import CryptoKit + +extension NotificationService { + + static func decrypt(payload: Data, salt: Data, auth: Data, privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data? { + guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to craete shared secret", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let keyMaterial = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: auth, sharedInfo: Data("Content-Encoding: auth\0".utf8), outputByteCount: 32) + + let keyInfo = info(type: "aesgcm", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let key = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: keyInfo, outputByteCount: 16) + + let nonceInfo = info(type: "nonce", clientPublicKey: privateKey.publicKey.x963Representation, serverPublicKey: publicKey.x963Representation) + let nonce = HKDF.deriveKey(inputKeyMaterial: keyMaterial, salt: salt, info: nonceInfo, outputByteCount: 12) + + let nonceData = nonce.withUnsafeBytes(Array.init) + + guard let sealedBox = try? AES.GCM.SealedBox(combined: nonceData + payload) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to create sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + guard let plaintext = try? AES.GCM.open(sealedBox, using: key) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) + return nil + } + + let paddingLength = Int(plaintext[0]) * 256 + Int(plaintext[1]) + guard plaintext.count >= 2 + paddingLength else { + print("1") + fatalError() + } + let unpadded = plaintext.suffix(from: paddingLength + 2) + + return Data(unpadded) + } + + static private func info(type: String, clientPublicKey: Data, serverPublicKey: Data) -> Data { + var info = Data() + + info.append("Content-Encoding: ".data(using: .utf8)!) + info.append(type.data(using: .utf8)!) + info.append(0) + info.append("P-256".data(using: .utf8)!) + info.append(0) + info.append(0) + info.append(65) + info.append(clientPublicKey) + info.append(0) + info.append(65) + info.append(serverPublicKey) + + return info + } +} + +extension NotificationService { + struct MastodonNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + + } +} diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift new file mode 100644 index 000000000..911866f17 --- /dev/null +++ b/NotificationService/NotificationService.swift @@ -0,0 +1,103 @@ +// +// NotificationService.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-23. +// + +import UserNotifications +import CommonOSLog +import CryptoKit +import AlamofireImage +import Base85 +import Keys + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let privateKey = AppSecret.default.notificationPrivateKey! + + guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, + let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid payload", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedPublicKey = bestAttemptContent.userInfo["k"] as? String, + let publicKey = NotificationService.publicKey(encodedPublicKey: encodedPublicKey) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid public key", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + guard let encodedSalt = bestAttemptContent.userInfo["s"] as? String, + let salt = Data(base85Encoded: encodedSalt, options: [], encoding: .z85) else { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: invalid salt", ((#file as NSString).lastPathComponent), #line, #function) + contentHandler(bestAttemptContent) + return + } + + let auth = AppSecret.default.notificationAuth + guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), + let notification = try? JSONDecoder().decode(MastodonNotification.self, from: plaintextData) else { + contentHandler(bestAttemptContent) + return + } + + bestAttemptContent.title = notification.title + bestAttemptContent.subtitle = "" + bestAttemptContent.body = notification.body + + if let urlString = notification.icon, let url = URL(string: urlString) { + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") + try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) + let filename = url.lastPathComponent + let fileURL = temporaryDirectoryURL.appendingPathComponent(filename) + + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let _ = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + try? image.pngData()?.write(to: fileURL) + if let attachment = try? UNNotificationAttachment(identifier: filename, url: fileURL, options: nil) { + bestAttemptContent.attachments = [attachment] + } + } + contentHandler(bestAttemptContent) + }) + } else { + contentHandler(bestAttemptContent) + } + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} + +extension NotificationService { + static func publicKey(encodedPublicKey: String) -> P256.KeyAgreement.PublicKey? { + guard let publicKeyData = Data(base85Encoded: encodedPublicKey, options: [], encoding: .z85) else { return nil } + return try? P256.KeyAgreement.PublicKey(x963Representation: publicKeyData) + } +} diff --git a/Podfile b/Podfile index d4bec65d4..bb929277e 100644 --- a/Podfile +++ b/Podfile @@ -23,4 +23,20 @@ target 'Mastodon' do # Pods for testing end + target 'NotificationService' do + + end + end + +plugin 'cocoapods-keys', { + :project => "Mastodon", + :keys => [ + "notification_endpoint", + "notification_endpoint_debug", + "notification_key_nonce", + "notification_key_nonce_debug", + "notification_key_auth", + "notification_key_auth_debug" + ] +} \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index 4f553c4e3..d34a2ada5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,12 +1,14 @@ PODS: - DateToolsSwift (5.0.0) - Kanna (5.2.4) + - Keys (1.0.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" DEPENDENCIES: - DateToolsSwift (~> 5.0.0) - Kanna (~> 5.2.2) + - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" @@ -17,12 +19,17 @@ SPEC REPOS: - SwiftGen - "UITextField+Shake" +EXTERNAL SOURCES: + Keys: + :path: Pods/CocoaPodsKeys + SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f + Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 30e8e3a555251a512e7b5e91183747152f126e7a +PODFILE CHECKSUM: b99204f58cb11d471cfad7269bbf0abb853dc953 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index 23957fa16..d3cddb071 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ arch -x86_64 pod install - [AlamofireNetworkActivityIndicator](https://github.com/Alamofire/AlamofireNetworkActivityIndicator) - [Alamofire](https://github.com/Alamofire/Alamofire) - [CommonOSLog](https://github.com/mainasuk/CommonOSLog) +- [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) - [Kingfisher](https://github.com/onevcat/Kingfisher) From 078c89adc7541cd7f0695d8c6a79a9f164e0f1d7 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 24 Apr 2021 21:50:14 -0700 Subject: [PATCH 308/400] fix: emptyView use tableView.readableContentGuide in pick server scene --- .../MastodonPickServerViewController.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 39ed714d7..06fc4ce23 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -84,15 +84,6 @@ extension MastodonPickServerViewController { nextStepButton.heightAnchor.constraint(equalToConstant: MastodonPickServerViewController.actionButtonHeight).priority(.defaultHigh), view.layoutMarginsGuide.bottomAnchor.constraint(equalTo: nextStepButton.bottomAnchor, constant: WelcomeViewController.viewBottomPaddingHeight), ]) - - emptyStateView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(emptyStateView) - NSLayoutConstraint.activate([ - emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), - emptyStateView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), - emptyStateView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), - ]) // fix AutoLayout warning when observe before view appear viewModel.viewWillAppear @@ -125,6 +116,16 @@ extension MastodonPickServerViewController { nextStepButton.topAnchor.constraint(equalTo: tableView.bottomAnchor, constant: 7), ]) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(emptyStateView) + NSLayoutConstraint.activate([ + emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: tableView.readableContentGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: tableView.readableContentGuide.trailingAnchor), + nextStepButton.topAnchor.constraint(equalTo: emptyStateView.bottomAnchor, constant: 21), + ]) + view.sendSubviewToBack(emptyStateView) + switch viewModel.mode { case .signIn: nextStepButton.setTitle(L10n.Common.Controls.Actions.signIn, for: .normal) From 479f21fd9f3ae77f1763266e712a1195c4ef6c16 Mon Sep 17 00:00:00 2001 From: ihugo Date: Fri, 23 Apr 2021 09:52:22 +0800 Subject: [PATCH 309/400] chore: remove secondary and rename elevated color --- Mastodon/Generated/Assets.swift | 3 +- .../secondary.colorset/Contents.json | 38 ------------------- .../Contents.json | 12 +++--- .../Contents.json | 12 +++--- .../Contents.json | 6 +-- Mastodon/Scene/Report/ReportFooterView.swift | 2 +- Mastodon/Scene/Report/ReportHeaderView.swift | 2 +- .../Scene/Report/ReportViewController.swift | 2 +- 8 files changed, 19 insertions(+), 58 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/Background/{elevatedPrimary.colorset => system.elevated.background.colorset}/Contents.json (88%) diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index aba5e4f6b..911544485 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -41,14 +41,13 @@ internal enum Asset { internal static let alertYellow = ColorAsset(name: "Colors/Background/alert.yellow") internal static let dangerBorder = ColorAsset(name: "Colors/Background/danger.border") internal static let danger = ColorAsset(name: "Colors/Background/danger") - internal static let elevatedPrimary = ColorAsset(name: "Colors/Background/elevatedPrimary") internal static let mediaTypeIndicotor = ColorAsset(name: "Colors/Background/media.type.indicotor") internal static let navigationBar = ColorAsset(name: "Colors/Background/navigationBar") internal static let onboardingBackground = ColorAsset(name: "Colors/Background/onboarding.background") - internal static let secondary = ColorAsset(name: "Colors/Background/secondary") internal static let secondaryGroupedSystemBackground = ColorAsset(name: "Colors/Background/secondary.grouped.system.background") internal static let secondarySystemBackground = ColorAsset(name: "Colors/Background/secondary.system.background") internal static let systemBackground = ColorAsset(name: "Colors/Background/system.background") + internal static let systemElevatedBackground = ColorAsset(name: "Colors/Background/system.elevated.background") internal static let systemGroupedBackground = ColorAsset(name: "Colors/Background/system.grouped.background") internal static let tertiarySystemBackground = ColorAsset(name: "Colors/Background/tertiary.system.background") internal static let tertiarySystemGroupedBackground = ColorAsset(name: "Colors/Background/tertiary.system.grouped.background") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json deleted file mode 100644 index 5e7067405..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "254", - "green" : "255", - "red" : "254" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "46", - "green" : "44", - "red" : "44" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json index 55f84c267..5e7067405 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.grouped.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xFE", - "green" : "0xFF", - "red" : "0xFE" + "blue" : "254", + "green" : "255", + "red" : "254" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json index bd6f07f25..82abbf254 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/secondary.system.background.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE1", - "red" : "0xD9" + "blue" : "232", + "green" : "225", + "red" : "217" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0x2E", - "green" : "0x2C", - "red" : "0x2C" + "blue" : "46", + "green" : "44", + "red" : "44" } }, "idiom" : "universal" diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json similarity index 88% rename from Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json index 82edd034b..e13fb4690 100644 --- a/Mastodon/Resources/Assets.xcassets/Colors/Background/elevatedPrimary.colorset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Colors/Background/system.elevated.background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "30", - "green" : "28", - "red" : "28" + "blue" : "0x1E", + "green" : "0x1C", + "red" : "0x1C" } }, "idiom" : "universal" diff --git a/Mastodon/Scene/Report/ReportFooterView.swift b/Mastodon/Scene/Report/ReportFooterView.swift index 16948f58e..a64a556d3 100644 --- a/Mastodon/Scene/Report/ReportFooterView.swift +++ b/Mastodon/Scene/Report/ReportFooterView.swift @@ -53,7 +53,7 @@ final class ReportFooterView: UIView { override init(frame: CGRect) { super.init(frame: frame) - self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color stackview.addArrangedSubview(nextStepButton) stackview.addArrangedSubview(skipButton) diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift index bace3ada5..414196e86 100644 --- a/Mastodon/Scene/Report/ReportHeaderView.swift +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -63,7 +63,7 @@ final class ReportHeaderView: UIView { override init(frame: CGRect) { super.init(frame: frame) - self.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + self.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color stackview.addArrangedSubview(titleLabel) stackview.addArrangedSubview(contentLabel) addSubview(stackview) diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index b00e4a3ee..14eed14ca 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -46,7 +46,7 @@ class ReportViewController: UIViewController, NeedsDependency { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.setContentHuggingPriority(.defaultLow, for: .vertical) - view.backgroundColor = Asset.Colors.Background.elevatedPrimary.color + view.backgroundColor = Asset.Colors.Background.systemElevatedBackground.color return view }() From cbc828eec2a5ae13797f7daf9fe17684fc1e71c7 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 15:36:40 +0800 Subject: [PATCH 310/400] refactor: remove UI part from ReportViewmodel --- ...meTimelineViewController+DebugAction.swift | 1 - .../Scene/Report/ReportViewController.swift | 36 ++++++++-- Mastodon/Scene/Report/ReportViewModel.swift | 65 +++++++------------ .../MastodonSDK/API/Mastodon+API+Report.swift | 17 ++++- 4 files changed, 67 insertions(+), 52 deletions(-) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index fca20c92b..eaad67b7c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -362,7 +362,6 @@ extension HomeTimelineViewController { // 106093402888557459 let viewModel = ReportViewModel( context: self.context, - coordinator: self.coordinator, domain: authenticationBox.domain, userId: userId, statusId: statusId diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index 14eed14ca..0bdd72bc9 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -12,6 +12,7 @@ import CoreDataStack import os.log import UIKit import TwitterTextEditor +import MastodonSDK class ReportViewController: UIViewController, NeedsDependency { static let kAnimationDuration: TimeInterval = 0.33 @@ -84,6 +85,12 @@ class ReportViewController: UIViewController, NeedsDependency { super.viewDidLoad() setupView() + + viewModel.setupDiffableDataSource( + for: tableView, + dependency: self + ) + bindViewModel() bindActions() } @@ -127,8 +134,7 @@ class ReportViewController: UIViewController, NeedsDependency { step1Skip: step1Skip.eraseToAnyPublisher(), step2Continue: step2Continue.eraseToAnyPublisher(), step2Skip: step2Skip.eraseToAnyPublisher(), - cancel: cancel.eraseToAnyPublisher(), - tableView: tableView + cancel: cancel.eraseToAnyPublisher() ) let output = viewModel.transform(input: input) output?.currentStep @@ -161,12 +167,28 @@ class ReportViewController: UIViewController, NeedsDependency { .assign(to: \.nextStepButton.isEnabled, on: footer) .store(in: &disposeBag) - output?.reportSuccess + output?.reportResult + .print() .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] (_) in - self?.dismiss(animated: true, completion: nil) - }) - .store(in: &disposeBag) + .sink(receiveCompletion: { _ in + }, receiveValue: { [weak self] data in + let (success, error) = data + if success { + self?.dismiss(animated: true, completion: nil) + } else if let error = error { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + self?.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + }) + .store(in: &disposeBag) } private func setupNavigation() { diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index a7bba0a7e..8ee5a44a9 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -13,7 +13,7 @@ import MastodonSDK import UIKit import os.log -class ReportViewModel: NSObject, NeedsDependency { +class ReportViewModel: NSObject { typealias FileReportQuery = Mastodon.API.Reports.FileReportQuery enum Step: Int { @@ -23,7 +23,6 @@ class ReportViewModel: NSObject, NeedsDependency { // confirm set only once weak var context: AppContext! { willSet { precondition(context == nil) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } var userId: String var statusId: String? @@ -34,7 +33,6 @@ class ReportViewModel: NSObject, NeedsDependency { var diffableDataSource: UITableViewDiffableDataSource? let continueEnableSubject = CurrentValueSubject(false) let sendEnableSubject = CurrentValueSubject(false) - let reportSuccess = PassthroughSubject() struct Input { let didToggleSelected: AnyPublisher @@ -44,24 +42,21 @@ class ReportViewModel: NSObject, NeedsDependency { let step2Continue: AnyPublisher let step2Skip: AnyPublisher let cancel: AnyPublisher - let tableView: UITableView } struct Output { let currentStep: AnyPublisher let continueEnableSubject: AnyPublisher let sendEnableSubject: AnyPublisher - let reportSuccess: AnyPublisher + let reportResult: AnyPublisher<(Bool, Error?), Never> } init(context: AppContext, - coordinator: SceneCoordinator, domain: String, userId: String, statusId: String? ) { self.context = context - self.coordinator = coordinator self.userId = userId self.statusId = statusId self.statusFetchedResultsController = StatusFetchedResultsController( @@ -86,17 +81,12 @@ class ReportViewModel: NSObject, NeedsDependency { } let domain = activeMastodonAuthenticationBox.domain - setupDiffableDataSource( - for: input.tableView, - dependency: self - ) - // data binding bindData(input: input) // step1 and step2 binding bindForStep1(input: input) - bindForStep2( + let reportResult = bindForStep2( input: input, domain: domain, activeMastodonAuthenticationBox: activeMastodonAuthenticationBox @@ -114,7 +104,7 @@ class ReportViewModel: NSObject, NeedsDependency { currentStep: currentStep.eraseToAnyPublisher(), continueEnableSubject: continueEnableSubject.eraseToAnyPublisher(), sendEnableSubject: sendEnableSubject.eraseToAnyPublisher(), - reportSuccess: reportSuccess.eraseToAnyPublisher() + reportResult: reportResult ) } @@ -172,42 +162,35 @@ class ReportViewModel: NSObject, NeedsDependency { .store(in: &disposeBag) } - func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) { + func bindForStep2(input: Input, domain: String, activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> AnyPublisher<(Bool, Error?), Never> { let skip = input.step2Skip.map { [weak self] value -> Void in guard let self = self else { return value } self.reportQuery.comment = nil return value } - - Publishers.Merge(skip, input.step2Continue) - .sink { [weak self] _ in - guard let self = self else { return } - self.context.apiService.report( + + return Publishers.Merge(skip, input.step2Continue) + .flatMap { [weak self] (_) -> AnyPublisher<(Bool, Error?), Never> in + guard let self = self else { + return Empty(completeImmediately: true).eraseToAnyPublisher() + } + + return self.context.apiService.report( domain: domain, query: self.reportQuery, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) - .sink { [weak self](data) in - switch data { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fail to file a report : %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - - let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) - let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) - alertController.addAction(okAction) - self?.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - case .finished: - self?.reportSuccess.send() - } - - } receiveValue: { (data) in - } - .store(in: &self.disposeBag) + .map({ (content) -> (Bool, Error?) in + return (true, nil) + }) + .eraseToAnyPublisher() + .tryCatch({ (error) -> AnyPublisher<(Bool, Error?), Never> in + return Just((false, error)).eraseToAnyPublisher() + }) + // to covert to AnyPublisher<(Bool, Error?), Never> + .replaceError(with: (false, nil)) + .eraseToAnyPublisher() } - .store(in: &disposeBag) + .eraseToAnyPublisher() } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift index 17bcd5331..1c63f744f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import enum NIOHTTP1.HTTPResponseStatus extension Mastodon.API.Reports { static func reportsEndpointURL(domain: String) -> URL { @@ -39,13 +40,23 @@ extension Mastodon.API.Reports { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - if let response = response as? HTTPURLResponse { + guard let response = response as? HTTPURLResponse else { + assertionFailure() + throw NSError() + } + + if response.statusCode == 200 { return Mastodon.Response.Content( - value: response.statusCode == 200, + value: true, response: response ) + } else { + let httpResponseStatus = HTTPResponseStatus(statusCode: response.statusCode) + throw Mastodon.API.Error( + httpResponseStatus: httpResponseStatus, + mastodonError: nil + ) } - return Mastodon.Response.Content(value: false, response: response) } .eraseToAnyPublisher() } From 85014802c441ef88645a3633a21f75d9c07b3748 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 15:48:37 +0800 Subject: [PATCH 311/400] style: rename `Id` to `ID` --- .../Scene/Report/ReportViewModel+Data.swift | 2 +- Mastodon/Scene/Report/ReportViewModel.swift | 12 +++--- .../MastodonSDK/API/Mastodon+API+Report.swift | 37 ++++++++++--------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index 005098b47..4dde72528 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -88,7 +88,7 @@ extension ReportViewModel { } if status.id == self.statusId { attribute.isSelected = true - self.reportQuery.append(statusId: status.id) + self.reportQuery.append(statusID: status.id) self.continueEnableSubject.send(true) } } diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index 8ee5a44a9..e1de26039 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -66,8 +66,8 @@ class ReportViewModel: NSObject { ) self.reportQuery = FileReportQuery( - accountId: userId, - statusIds: [], + accountID: userId, + statusIDs: [], comment: nil, forward: nil ) @@ -121,15 +121,15 @@ class ReportViewModel: NSObject { attribute.isSelected = !attribute.isSelected if attribute.isSelected { - self.reportQuery.append(statusId: status.id) + self.reportQuery.append(statusID: status.id) } else { - self.reportQuery.remove(statusId: status.id) + self.reportQuery.remove(statusID: status.id) } snapshot.reloadItems([item]) self.diffableDataSource?.apply(snapshot, animatingDifferences: false) - let continueEnable = (self.reportQuery.statusIds?.count ?? 0) > 0 + let continueEnable = (self.reportQuery.statusIDs?.count ?? 0) > 0 self.continueEnableSubject.send(continueEnable) } .store(in: &disposeBag) @@ -150,7 +150,7 @@ class ReportViewModel: NSObject { func bindForStep1(input: Input) { let skip = input.step1Skip.map { [weak self] value -> Void in guard let self = self else { return value } - self.reportQuery.statusIds?.removeAll() + self.reportQuery.statusIDs?.removeAll() return value } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift index 1c63f744f..fff746174 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -65,40 +65,41 @@ extension Mastodon.API.Reports { public extension Mastodon.API.Reports { class FileReportQuery: Codable, PostQuery { - public let accountId: String - public var statusIds: [String]? + public let accountID: Mastodon.Entity.Account.ID + public var statusIDs: [Mastodon.Entity.Status.ID]? public var comment: String? public let forward: Bool? enum CodingKeys: String, CodingKey { - case accountId = "account_id" - case statusIds = "status_ids" + case accountID = "account_id" + case statusIDs = "status_ids" case comment case forward } - public init(accountId: String, - statusIds: [String]?, - comment: String?, - forward: Bool?) { - self.accountId = accountId - self.statusIds = statusIds + public init( + accountID: Mastodon.Entity.Account.ID, + statusIDs: [Mastodon.Entity.Status.ID]?, + comment: String?, + forward: Bool?) { + self.accountID = accountID + self.statusIDs = statusIDs self.comment = comment self.forward = forward } - public func append(statusId: String) { - guard self.statusIds?.contains(statusId) != true else { return } - if self.statusIds == nil { - self.statusIds = [] + public func append(statusID: Mastodon.Entity.Status.ID) { + guard self.statusIDs?.contains(statusID) != true else { return } + if self.statusIDs == nil { + self.statusIDs = [] } - self.statusIds?.append(statusId) + self.statusIDs?.append(statusID) } - public func remove(statusId: String) { - guard let index = self.statusIds?.firstIndex(of: statusId) else { return } - self.statusIds?.remove(at: index) + public func remove(statusID: String) { + guard let index = self.statusIDs?.firstIndex(of: statusID) else { return } + self.statusIDs?.remove(at: index) } } } From db48e56dd9a07ee287ee499e665a9a22b35a19f2 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 15:51:45 +0800 Subject: [PATCH 312/400] refactor: remove negative constraints --- Mastodon/Scene/Report/ReportHeaderView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Mastodon/Scene/Report/ReportHeaderView.swift b/Mastodon/Scene/Report/ReportHeaderView.swift index 414196e86..8a6d957c8 100644 --- a/Mastodon/Scene/Report/ReportHeaderView.swift +++ b/Mastodon/Scene/Report/ReportHeaderView.swift @@ -70,7 +70,7 @@ final class ReportHeaderView: UIView { stackview.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - stackview.safeAreaLayoutGuide.topAnchor.constraint( + stackview.topAnchor.constraint( equalTo: self.topAnchor, constant: ReportView.verticalMargin ), @@ -78,13 +78,13 @@ final class ReportHeaderView: UIView { equalTo: self.readableContentGuide.leadingAnchor, constant: ReportView.horizontalMargin ), - stackview.bottomAnchor.constraint( - equalTo: self.bottomAnchor, - constant: -1 * ReportView.verticalMargin + self.bottomAnchor.constraint( + equalTo: stackview.bottomAnchor, + constant: ReportView.verticalMargin ), - stackview.trailingAnchor.constraint( - equalTo: self.readableContentGuide.trailingAnchor, - constant: -1 * ReportView.horizontalMargin + self.readableContentGuide.trailingAnchor.constraint( + equalTo: stackview.trailingAnchor, + constant: ReportView.horizontalMargin ) ]) } From 00794a259af341956944fe4f937ad82e5c98cd45 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 25 Apr 2021 00:44:38 -0700 Subject: [PATCH 313/400] fix: pick server scene Dark Mode --- .../Section/CategoryPickerSection.swift | 2 +- Mastodon/Generated/Assets.swift | 1 + .../background.colorset/Contents.json | 38 +++++++++++++++++++ .../TableViewCell/PickServerCell.swift | 2 +- .../TableViewCell/PickServerSearchCell.swift | 4 +- .../View/PickServerCategoryView.swift | 2 +- .../OnboardingViewControllerAppearance.swift | 5 +-- .../View/Button/PrimaryActionButton.swift | 22 +++++++++-- 8 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 456d193f3..938683f99 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -34,7 +34,7 @@ extension CategoryPickerSection { cell.categoryView.titleLabel.textColor = .white } } else { - cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.systemBackground.color + cell.categoryView.bgView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color cell.categoryView.bgView.applyShadow(color: Asset.Colors.brandBlue.color, alpha: 0, x: 0, y: 0, blur: 0.0) if case .all = item { cell.categoryView.titleLabel.textColor = Asset.Colors.brandBlue.color diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index d26424c75..a4b5692ba 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -84,6 +84,7 @@ internal enum Asset { internal static let bar = ColorAsset(name: "Colors/Slider/bar") } internal enum TextField { + internal static let background = ColorAsset(name: "Colors/TextField/background") internal static let highlight = ColorAsset(name: "Colors/TextField/highlight") internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") internal static let valid = ColorAsset(name: "Colors/TextField/valid") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json new file mode 100644 index 000000000..cde0cdf00 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/TextField/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "213", + "green" : "212", + "red" : "212" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.240", + "blue" : "128", + "green" : "118", + "red" : "118" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index bf2299122..9313aa5cc 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -27,7 +27,7 @@ class PickServerCell: UITableViewCell { let containerView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) - view.backgroundColor = Asset.Colors.Background.systemBackground.color + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color view.translatesAutoresizingMaskIntoConstraints = false return view }() diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index b708313ac..fb1be2aad 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -17,7 +17,7 @@ class PickServerSearchCell: UITableViewCell { private var bgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.systemBackground.color + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.maskedCorners = [ .layerMinXMinYCorner, @@ -30,7 +30,7 @@ class PickServerSearchCell: UITableViewCell { private var textFieldBgView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color.withAlphaComponent(0.6) + view.backgroundColor = Asset.Colors.TextField.background.color view.translatesAutoresizingMaskIntoConstraints = false view.layer.masksToBounds = true view.layer.cornerRadius = 6 diff --git a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift index 16d5a9fcc..fd1a3ea6d 100644 --- a/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift +++ b/Mastodon/Scene/Onboarding/PickServer/View/PickServerCategoryView.swift @@ -48,7 +48,7 @@ extension PickServerCategoryView { addSubview(bgView) addSubview(titleLabel) - bgView.backgroundColor = Asset.Colors.Background.systemBackground.color + bgView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color NSLayoutConstraint.activate([ bgView.leadingAnchor.constraint(equalTo: self.leadingAnchor), diff --git a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift index c4b26321a..0784b51ea 100644 --- a/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift +++ b/Mastodon/Scene/Onboarding/Share/OnboardingViewControllerAppearance.swift @@ -20,8 +20,7 @@ extension OnboardingViewControllerAppearance { static var viewBottomPaddingHeight: CGFloat { return 11 } func setupOnboardingAppearance() { - overrideUserInterfaceStyle = .light - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigationBarAppearance() @@ -43,7 +42,7 @@ extension OnboardingViewControllerAppearance { func setupNavigationBarBackgroundView() { let navigationBarBackgroundView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return view }() diff --git a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift index aa36fd237..8fefc06c8 100644 --- a/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift +++ b/Mastodon/Scene/Share/View/Button/PrimaryActionButton.swift @@ -38,9 +38,8 @@ extension PrimaryActionButton { private func _init() { titleLabel?.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) setTitleColor(.white, for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color), for: .normal) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) - setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color), for: .normal) + setupButtonBackground() applyCornerRadius(radius: 10) } @@ -68,4 +67,21 @@ extension PrimaryActionButton { isEnabled = true self.setTitle(originalButtonTitle, for: .disabled) } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + setupButtonBackground() + } + + func setupButtonBackground() { + if UIScreen.main.traitCollection.userInterfaceStyle == .light { + setTitleColor(.white, for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + + } else { + setTitleColor(UIColor.white.withAlphaComponent(0.5), for: .disabled) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .highlighted) + setBackgroundImage(UIImage.placeholder(color: Asset.Colors.brandBlue.color.withAlphaComponent(0.5)), for: .disabled) + } + } } From 491448e753e6a84f1011a52f85eecbc8c69efea4 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 25 Apr 2021 02:19:47 -0700 Subject: [PATCH 314/400] fix: register scene Dark Mode --- Mastodon/Generated/Assets.swift | 1 - .../Colors/Icon/photo.colorset/Contents.json | 20 -------------- .../MastodonRegisterViewController.swift | 26 +++++++++---------- .../MastodonServerRulesViewController.swift | 8 ++---- 4 files changed, 15 insertions(+), 40 deletions(-) delete mode 100644 Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index a4b5692ba..7f37f671b 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -63,7 +63,6 @@ internal enum Asset { internal static let normal = ColorAsset(name: "Colors/Button/normal") } internal enum Icon { - internal static let photo = ColorAsset(name: "Colors/Icon/photo") internal static let plus = ColorAsset(name: "Colors/Icon/plus") } internal enum Label { diff --git a/Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json deleted file mode 100644 index d4f558bfd..000000000 --- a/Mastodon/Resources/Assets.xcassets/Colors/Icon/photo.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "0.300", - "blue" : "67", - "green" : "60", - "red" : "60" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 009e26536..3c20ed733 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -79,9 +79,9 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) button.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) - button.imageView?.tintColor = Asset.Colors.Icon.photo.color - button.backgroundColor = .white - button.layer.cornerRadius = 45 + button.imageView?.tintColor = Asset.Colors.Label.secondary.color + button.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + button.layer.cornerRadius = 10 button.clipsToBounds = true return button @@ -93,7 +93,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let image = Asset.Circles.plusCircleFill.image.withRenderingMode(.alwaysTemplate) icon.image = image icon.tintColor = Asset.Colors.Icon.plus.color - icon.backgroundColor = .white + icon.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return icon }() @@ -109,7 +109,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, @@ -132,7 +132,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, @@ -149,7 +149,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.autocapitalizationType = .none textField.autocorrectionType = .no textField.keyboardType = .emailAddress - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, @@ -174,7 +174,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.autocorrectionType = .no textField.keyboardType = .asciiCapable textField.isSecureTextEntry = true - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, @@ -204,7 +204,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let textField = UITextField() textField.autocapitalizationType = .none textField.autocorrectionType = .no - textField.backgroundColor = .white + textField.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, @@ -336,8 +336,8 @@ extension MastodonRegisterViewController { ]) avatarButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - avatarButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), - avatarButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + avatarButton.heightAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), + avatarButton.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), avatarButton.centerXAnchor.constraint(equalTo: avatarView.centerXAnchor), avatarButton.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor), ]) @@ -345,8 +345,8 @@ extension MastodonRegisterViewController { plusIconImageView.translatesAutoresizingMaskIntoConstraints = false avatarView.addSubview(plusIconImageView) NSLayoutConstraint.activate([ - plusIconImageView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor), - plusIconImageView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), + plusIconImageView.centerXAnchor.constraint(equalTo: avatarButton.trailingAnchor), + plusIconImageView.centerYAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) // textfield diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index b51d66b2b..610d87f34 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -48,7 +48,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency let bottomContainerView: UIView = { let view = UIView() - view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return view }() @@ -58,7 +58,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency textView.textColor = .label textView.isSelectable = true textView.isEditable = false - textView.backgroundColor = Asset.Colors.Background.onboardingBackground.color + textView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return textView }() @@ -85,10 +85,6 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency extension MastodonServerRulesViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() From cd77afe4a1d3dc51e5ff2f4e95a3c7d14c2c2fbd Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 25 Apr 2021 03:02:31 -0700 Subject: [PATCH 315/400] fix: confirm email and resend email scene in Dark Mode --- .../MastodonConfirmEmailViewController.swift | 9 ++++++++- .../PickServer/MastodonPickServerViewController.swift | 3 --- .../Register/MastodonRegisterViewController.swift | 4 ---- .../ResendEmail/MastodonResendEmailViewController.swift | 9 ++++----- .../Scene/Onboarding/Welcome/WelcomeViewController.swift | 4 ---- 5 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift index 80a4eed15..54994cb1f 100644 --- a/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ConfirmEmail/MastodonConfirmEmailViewController.swift @@ -206,7 +206,7 @@ import SwiftUI struct MastodonConfirmEmailViewController_Previews: PreviewProvider { - static var previews: some View { + static var controls: some View { UIViewControllerPreview { let viewController = MastodonConfirmEmailViewController() return viewController @@ -214,6 +214,13 @@ struct MastodonConfirmEmailViewController_Previews: PreviewProvider { .previewLayout(.fixed(width: 375, height: 800)) } + static var previews: some View { + Group { + controls.colorScheme(.light) + controls.colorScheme(.dark) + } + } + } #endif diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 06fc4ce23..685709719 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -56,9 +56,6 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency extension MastodonPickServerViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 3c20ed733..007012d3c 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -238,10 +238,6 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O extension MastodonRegisterViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() diff --git a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift index d97209317..1d3a29cb5 100644 --- a/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift +++ b/Mastodon/Scene/Onboarding/ResendEmail/MastodonResendEmailViewController.swift @@ -41,13 +41,9 @@ final class MastodonResendEmailViewController: UIViewController, NeedsDependency extension MastodonResendEmailViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() - + setupOnboardingAppearance() navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(MastodonResendEmailViewController.cancelBarButtonItemPressed(_:))) webView.translatesAutoresizingMaskIntoConstraints = false @@ -72,3 +68,6 @@ extension MastodonResendEmailViewController { dismiss(animated: true, completion: nil) } } + +// MARK: - OnboardingViewControllerAppearance +extension MastodonResendEmailViewController: OnboardingViewControllerAppearance { } diff --git a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift index de89cd457..3654c9f08 100644 --- a/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift +++ b/Mastodon/Scene/Onboarding/Welcome/WelcomeViewController.swift @@ -64,10 +64,6 @@ final class WelcomeViewController: UIViewController, NeedsDependency { extension WelcomeViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .darkContent - } - override func viewDidLoad() { super.viewDidLoad() From 272f1c459e28596cc1868bbc075cc7afc21b7cf2 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sun, 25 Apr 2021 03:27:07 -0700 Subject: [PATCH 316/400] fix: add email svg with Dark Mode --- .../Asset/email.imageset/Contents.json | 10 + .../Asset/email.imageset/c1-1.svg | 342 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json index 1b7212647..3febbcacb 100644 --- a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/Contents.json @@ -3,6 +3,16 @@ { "filename" : "c1.svg", "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "c1-1.svg", + "idiom" : "universal" } ], "info" : { diff --git a/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg new file mode 100644 index 000000000..a316721b0 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Asset/email.imageset/c1-1.svg @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e9d015720b62b64794b64c6b33531b785f013356 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 18:28:47 +0800 Subject: [PATCH 317/400] refactor: use `tableView.allowsMultipleSelection` --- .../Diffiable/Section/ReportSection.swift | 8 +++++- .../Scene/Report/ReportViewController.swift | 9 ++++++- Mastodon/Scene/Report/ReportViewModel.swift | 4 --- .../Report/ReportedStatusTableviewCell.swift | 25 +++++++++---------- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index 86a12a9a3..eccf09d26 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -48,7 +48,13 @@ extension ReportSection { ) } - cell.setupSelected(attribute.isSelected) + // defalut to select the report status + if attribute.isSelected { + tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) + } else { + tableView.deselectRow(at: indexPath, animated: false) + } + return cell default: return nil diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index 0bdd72bc9..487efa124 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -68,6 +68,7 @@ class ReportViewController: UIViewController, NeedsDependency { tableView.backgroundColor = .clear tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self + tableView.allowsMultipleSelection = true return tableView }() @@ -277,7 +278,13 @@ extension ReportViewController: UITableViewDelegate { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { return } - + didToggleSelected.send(item) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return + } didToggleSelected.send(item) } } diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index e1de26039..26c0f2f2a 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -113,7 +113,6 @@ class ReportViewModel: NSObject { input.didToggleSelected.sink { [weak self] (item) in guard let self = self else { return } guard case let .reportStatus(objectID, attribute) = item else { return } - guard var snapshot = self.diffableDataSource?.snapshot() else { return } let managedObjectContext = self.statusFetchedResultsController.fetchedResultsController.managedObjectContext guard let status = managedObjectContext.object(with: objectID) as? Status else { return @@ -126,9 +125,6 @@ class ReportViewModel: NSObject { self.reportQuery.remove(statusID: status.id) } - snapshot.reloadItems([item]) - self.diffableDataSource?.apply(snapshot, animatingDifferences: false) - let continueEnable = (self.reportQuery.statusIDs?.count ?? 0) > 0 self.continueEnableSubject.send(continueEnable) } diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 8329124cc..0c63398d1 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -20,7 +20,6 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() - var checked: Bool = false let statusView = StatusView() let separatorLine = UIView.separatorLine @@ -42,7 +41,6 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { override func prepareForReuse() { super.prepareForReuse() - checked = false statusView.updateContentWarningDisplay(isHidden: true, animated: false) statusView.statusMosaicImageViewContainer.contentWarningOverlayView.isUserInteractionEnabled = true statusView.pollTableView.dataSource = nil @@ -75,11 +73,22 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { if highlighted { checkbox.image = UIImage(systemName: "checkmark.circle.fill") checkbox.tintColor = Asset.Colors.Label.highlight.color - } else if !checked { + } else if !isSelected { checkbox.image = UIImage(systemName: "circle") checkbox.tintColor = Asset.Colors.Label.secondary.color } } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + if isSelected { + checkbox.image = UIImage(systemName: "checkmark.circle.fill") + } else { + checkbox.image = UIImage(systemName: "circle") + } + checkbox.tintColor = Asset.Colors.Label.secondary.color + } } extension ReportedStatusTableViewCell { @@ -127,16 +136,6 @@ extension ReportedStatusTableViewCell { resetSeparatorLineLayout() } - - func setupSelected(_ selected: Bool) { - checked = selected - if selected { - checkbox.image = UIImage(systemName: "checkmark.circle.fill") - } else { - checkbox.image = UIImage(systemName: "circle") - } - checkbox.tintColor = Asset.Colors.Label.secondary.color - } } extension ReportedStatusTableViewCell { From 3f62272162e3b5ac28511b0f1556a92609d861d1 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 18:45:17 +0800 Subject: [PATCH 318/400] fix: lost comment if send without comment first --- .../Scene/Report/ReportViewModel+Data.swift | 2 +- Mastodon/Scene/Report/ReportViewModel.swift | 37 ++++++++++++++++--- .../MastodonSDK/API/Mastodon+API+Report.swift | 14 ------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index 4dde72528..dcb715fba 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -88,7 +88,7 @@ extension ReportViewModel { } if status.id == self.statusId { attribute.isSelected = true - self.reportQuery.append(statusID: status.id) + self.append(statusID: status.id) self.continueEnableSubject.send(true) } } diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index 26c0f2f2a..4864145f5 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -26,6 +26,9 @@ class ReportViewModel: NSObject { var userId: String var statusId: String? + var statusIDs = [Mastodon.Entity.Status.ID]() + var comment: String? + var reportQuery: FileReportQuery var disposeBag = Set() let currentStep = CurrentValueSubject(.one) @@ -120,19 +123,19 @@ class ReportViewModel: NSObject { attribute.isSelected = !attribute.isSelected if attribute.isSelected { - self.reportQuery.append(statusID: status.id) + self.append(statusID: status.id) } else { - self.reportQuery.remove(statusID: status.id) + self.remove(statusID: status.id) } - let continueEnable = (self.reportQuery.statusIDs?.count ?? 0) > 0 + let continueEnable = self.statusIDs.count > 0 self.continueEnableSubject.send(continueEnable) } .store(in: &disposeBag) input.comment.assign( to: \.comment, - on: self.reportQuery + on: self ) .store(in: &disposeBag) input.comment.sink { [weak self] (comment) in @@ -150,7 +153,13 @@ class ReportViewModel: NSObject { return value } - Publishers.Merge(skip, input.step1Continue) + let step1Continue = input.step1Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.statusIDs = self.statusIDs + return value + } + + Publishers.Merge(skip, step1Continue) .sink { [weak self] _ in self?.currentStep.value = .two self?.sendEnableSubject.send(false) @@ -164,8 +173,14 @@ class ReportViewModel: NSObject { self.reportQuery.comment = nil return value } + + let step2Continue = input.step2Continue.map { [weak self] value -> Void in + guard let self = self else { return value } + self.reportQuery.comment = self.comment + return value + } - return Publishers.Merge(skip, input.step2Continue) + return Publishers.Merge(skip, step2Continue) .flatMap { [weak self] (_) -> AnyPublisher<(Bool, Error?), Never> in guard let self = self else { return Empty(completeImmediately: true).eraseToAnyPublisher() @@ -189,4 +204,14 @@ class ReportViewModel: NSObject { } .eraseToAnyPublisher() } + + func append(statusID: Mastodon.Entity.Status.ID) { + guard self.statusIDs.contains(statusID) != true else { return } + self.statusIDs.append(statusID) + } + + func remove(statusID: String) { + guard let index = self.statusIDs.firstIndex(of: statusID) else { return } + self.statusIDs.remove(at: index) + } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift index fff746174..6ba8c3cf5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Report.swift @@ -87,19 +87,5 @@ public extension Mastodon.API.Reports { self.comment = comment self.forward = forward } - - public func append(statusID: Mastodon.Entity.Status.ID) { - guard self.statusIDs?.contains(statusID) != true else { return } - if self.statusIDs == nil { - self.statusIDs = [] - } - - self.statusIDs?.append(statusID) - } - - public func remove(statusID: String) { - guard let index = self.statusIDs?.firstIndex(of: statusID) else { return } - self.statusIDs?.remove(at: index) - } } } From 1356e3755f486aba5fb912a15bc1b863dd3c3b46 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 19:49:49 +0800 Subject: [PATCH 319/400] fix: make the keyboard to be laid out properly --- .../Scene/Report/ReportViewController.swift | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index 487efa124..9cf506a30 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -79,9 +79,19 @@ class ReportViewController: UIViewController, NeedsDependency { textView.placeholder = L10n.Scene.Report.textPlaceholder textView.backgroundColor = .clear textView.delegate = self + textView.isScrollEnabled = true + textView.keyboardDismissMode = .onDrag return textView }() + lazy var bottomSpacing: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + var bottomConstraint: NSLayoutConstraint! + override func viewDidLoad() { super.viewDidLoad() @@ -104,6 +114,7 @@ class ReportViewController: UIViewController, NeedsDependency { stackview.addArrangedSubview(header) stackview.addArrangedSubview(contentView) stackview.addArrangedSubview(footer) + stackview.addArrangedSubview(bottomSpacing) contentView.addSubview(tableView) @@ -116,9 +127,12 @@ class ReportViewController: UIViewController, NeedsDependency { tableView.topAnchor.constraint(equalTo: contentView.topAnchor), tableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), tableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + tableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), ]) + self.bottomConstraint = bottomSpacing.heightAnchor.constraint(equalToConstant: 0) + bottomConstraint.isActive = true + header.step = .one } @@ -190,6 +204,29 @@ class ReportViewController: UIViewController, NeedsDependency { } }) .store(in: &disposeBag) + + Publishers.CombineLatest( + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.endFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] state, endFrame in + guard let self = self else { return } + + guard state == .dock else { + self.bottomConstraint.constant = 0.0 + return + } + + let contentFrame = self.view.convert(self.view.frame, to: nil) + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.bottomConstraint.constant = 0.0 + return + } + + self.bottomConstraint.constant = padding + }) + .store(in: &disposeBag) } private func setupNavigation() { @@ -231,9 +268,9 @@ class ReportViewController: UIViewController, NeedsDependency { constant: ReportView.horizontalMargin ), self.textView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), - self.textView.trailingAnchor.constraint( - equalTo: self.contentView.safeAreaLayoutGuide.trailingAnchor, - constant: -1 * ReportView.horizontalMargin + self.contentView.trailingAnchor.constraint( + equalTo: self.textView.trailingAnchor, + constant: ReportView.horizontalMargin ), ]) self.textView.layoutIfNeeded() From 1b05d787df2f88d7ec42aea0ff351236fb30b8b1 Mon Sep 17 00:00:00 2001 From: ihugo Date: Sun, 25 Apr 2021 23:49:19 +0800 Subject: [PATCH 320/400] fix: make report-status can be revealed --- .../Diffiable/Section/ReportSection.swift | 3 +- .../StatusProvider/StatusProviderFacade.swift | 28 +++++++++++ .../Report/ReportViewModel+Diffable.swift | 2 +- .../Report/ReportedStatusTableviewCell.swift | 49 ++++++++++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index eccf09d26..e14a959f7 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -21,7 +21,7 @@ enum ReportSection: Equatable, Hashable { extension ReportSection { static func tableViewDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency, + dependency: ReportViewController, managedObjectContext: NSManagedObjectContext, timestampUpdatePublisher: AnyPublisher ) -> UITableViewDiffableDataSource { @@ -33,6 +33,7 @@ extension ReportSection { switch item { case .reportStatus(let objectID, let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ReportedStatusTableViewCell.self), for: indexPath) as! ReportedStatusTableViewCell + cell.dependency = dependency let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" managedObjectContext.performAndWait { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 2e6102227..03f84216a 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -498,6 +498,34 @@ extension StatusProviderFacade { .store(in: &dependency.context.disposeBag) } + static func responseToStatusContentWarningRevealAction(dependency: ReportViewController, cell: UITableViewCell) { + let status = Future { promise in + guard let diffableDataSource = dependency.viewModel.diffableDataSource, + let indexPath = dependency.tableView.indexPath(for: cell), + let item = diffableDataSource.itemIdentifier(for: indexPath) else { + promise(.success(nil)) + return + } + let managedObjectContext = dependency.viewModel.statusFetchedResultsController + .fetchedResultsController + .managedObjectContext + + switch item { + case .reportStatus(let objectID, _): + managedObjectContext.perform { + let status = managedObjectContext.object(with: objectID) as! Status + promise(.success(status)) + } + default: + promise(.success(nil)) + } + } + + _responseToStatusContentWarningRevealAction( + dependency: dependency, + status: status + ) + } } extension StatusProviderFacade { diff --git a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift index f737381b1..73d6ffa0d 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Diffable.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Diffable.swift @@ -13,7 +13,7 @@ import CoreDataStack extension ReportViewModel { func setupDiffableDataSource( for tableView: UITableView, - dependency: NeedsDependency + dependency: ReportViewController ) { let timestampUpdatePublisher = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 0c63398d1..3cbfface5 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -17,6 +17,7 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 + var dependency: ReportViewController? var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() @@ -63,6 +64,9 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { override func layoutSubviews() { super.layoutSubviews() + + // precondition: app is active + guard UIApplication.shared.applicationState == .active else { return } DispatchQueue.main.async { self.statusView.drawContentWarningImageView() } @@ -127,8 +131,10 @@ extension ReportedStatusTableViewCell { resetSeparatorLineLayout() selectionStyle = .none + statusView.delegate = self + statusView.statusMosaicImageViewContainer.delegate = self statusView.actionToolbarContainer.isHidden = true - statusView.isUserInteractionEnabled = false + statusView.contentWarningOverlayView.blurContentImageView.backgroundColor = backgroundColor } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { @@ -168,3 +174,44 @@ extension ReportedStatusTableViewCell { } } } + +extension ReportedStatusTableViewCell: MosaicImageViewContainerDelegate { + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + + } + + func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } +} + +extension ReportedStatusTableViewCell: StatusViewDelegate { + func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) { + } + + func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) { + } + + func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { + guard let dependency = self.dependency else { return } + StatusProviderFacade.responseToStatusContentWarningRevealAction(dependency: dependency, cell: self) + } + + func statusView(_ statusView: StatusView, pollVoteButtonPressed button: UIButton) { + } + + func statusView(_ statusView: StatusView, activeLabel: ActiveLabel, didSelectActiveEntity entity: ActiveEntity) { + } +} From ad8df6813f141795e2d7b451dbb7d89a00199875 Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 26 Apr 2021 15:58:49 +0800 Subject: [PATCH 321/400] fix: add entries for the reporting --- Localization/app.json | 3 +- .../Diffiable/Section/StatusSection.swift | 53 ++++++++++++++++++- Mastodon/Generated/Strings.swift | 4 ++ .../UserProvider/UserProviderFacade.swift | 18 +++++++ .../Resources/en.lproj/Localizable.strings | 1 + ...meTimelineViewController+DebugAction.swift | 41 -------------- .../Scene/Report/ReportViewController.swift | 2 +- .../Scene/Report/ReportViewModel+Data.swift | 7 +-- Mastodon/Scene/Report/ReportViewModel.swift | 16 +++--- 9 files changed, 89 insertions(+), 56 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index fcd960293..45ec698ad 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -52,7 +52,8 @@ "share": "Share", "share_user": "Share %s", "open_in_safari": "Open in Safari", - "skip": "Skip" + "skip": "Skip", + "report_user": "Report %s" }, "status": { "user_reblogged": "%s reblogged", diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 97e4cdf9c..ea3332f14 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -301,6 +301,9 @@ extension StatusSection { case is NotificationStatusTableViewCell: let notificationTableViewCell = cell as! NotificationStatusTableViewCell parent = notificationTableViewCell.delegate?.parent() + case is ReportedStatusTableViewCell: + let reportTableViewCell = cell as! ReportedStatusTableViewCell + parent = reportTableViewCell.dependency default: parent = nil assertionFailure("unknown cell") @@ -394,7 +397,12 @@ extension StatusSection { } // toolbar - StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + StatusSection.configureActionToolBar( + cell: cell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) // separator line if let statusTableViewCell = cell as? StatusTableViewCell { @@ -418,7 +426,12 @@ extension StatusSection { } receiveValue: { change in guard case .update(let object) = change.changeType, let status = object as? Status else { return } - StatusSection.configureActionToolBar(cell: cell, status: status, requestUserID: requestUserID) + StatusSection.configureActionToolBar( + cell: cell, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) os_log("%{public}s[%{public}ld], %{public}s: reblog count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.reblogsCount.intValue) os_log("%{public}s[%{public}ld], %{public}s: like count label for status %s did update: %ld", (#file as NSString).lastPathComponent, #line, #function, status.id, status.favouritesCount.intValue) @@ -573,6 +586,7 @@ extension StatusSection { static func configureActionToolBar( cell: StatusCell, + dependency: NeedsDependency, status: Status, requestUserID: String ) { @@ -600,6 +614,8 @@ extension StatusSection { }() cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + + self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } static func configurePoll( @@ -726,4 +742,37 @@ extension StatusSection { guard let number = number, number > 0 else { return "" } return String(number) } + + private static func setupStatusMoreButtonMenu( + cell: StatusCell, + dependency: NeedsDependency, + status: Status) { + + cell.statusView.actionToolbarContainer.moreButton.menu = nil + + guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let author = (status.reblog ?? status).author + guard authenticationBox.userID != author.id else { + return + } + var children: [UIMenuElement] = [] + let name = author.displayNameWithFallback + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { _ in + let viewModel = ReportViewModel( + context: dependency.context, + domain: authenticationBox.domain, + user: status.author, + status: status) + dependency.coordinator.present( + scene: .report(viewModel: viewModel), + from: nil, + transition: .modal(animated: true, completion: nil) + ) + } + children.append(reportAction) + cell.statusView.actionToolbarContainer.moreButton.menu = UIMenu(title: "", options: [], children: children) + cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true + } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d9baf8665..7fb024417 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -80,6 +80,10 @@ internal enum L10n { internal static let preview = L10n.tr("Localizable", "Common.Controls.Actions.Preview") /// Remove internal static let remove = L10n.tr("Localizable", "Common.Controls.Actions.Remove") + /// Report %@ + internal static func reportUser(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.ReportUser", String(describing: p1)) + } /// Save internal static let save = L10n.tr("Localizable", "Common.Controls.Actions.Save") /// Save photo diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b5f4dd32f..4f0a2bfee 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -218,6 +218,24 @@ extension UserProviderFacade { children.append(shareAction) } + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let viewModel = ReportViewModel( + context: provider.context, + domain: authenticationBox.domain, + user: mastodonUser, + status: nil) + provider.coordinator.present( + scene: .report(viewModel: viewModel), + from: provider, + transition: .modal(animated: true, completion: nil) + ) + } + children.append(reportAction) + return UIMenu(title: "", options: [], children: children) } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e16d3e886..4ce29afd8 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -24,6 +24,7 @@ Please check your internet connection."; "Common.Controls.Actions.OpenInSafari" = "Open in Safari"; "Common.Controls.Actions.Preview" = "Preview"; "Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.ReportUser" = "Report %@"; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index eaad67b7c..b1dd19447 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -41,10 +41,6 @@ extension HomeTimelineViewController { guard let self = self else { return } self.showSettings(action) }, - UIAction(title: "Report", image: UIImage(systemName: "exclamationmark.bubble"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showReportAction(action) - }, UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in guard let self = self else { return } self.signOutAction(action) @@ -339,42 +335,5 @@ extension HomeTimelineViewController { transition: .modal(animated: true, completion: nil) ) } - - @objc private func showReportAction(_ sender: UIAction) { - let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) - alertController.addTextField() - alertController.addTextField() - guard let accountTextField = alertController.textFields?.first else { return } - guard let statusTextField = alertController.textFields?.last else { return } - accountTextField.placeholder = "User ID" - statusTextField.placeholder = "Status ID" - accountTextField.text = "212477" - statusTextField.text = "106103767536113615" - let showAction = UIAlertAction(title: "Show", style: .default) { [weak self] _ in - guard let self = self else { return } - - guard let userId = accountTextField.text else { return } - guard let statusId = statusTextField.text else { return } - guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - - // itodo: delete them - // 31803 - // 106093402888557459 - let viewModel = ReportViewModel( - context: self.context, - domain: authenticationBox.domain, - userId: userId, - statusId: statusId - ) - self.coordinator.present( - scene: .report(viewModel: viewModel), - from: self, transition: .modal(animated: true, completion: nil)) - } - alertController.addAction(showAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) - } - } #endif diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index 9cf506a30..dea962dca 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -242,7 +242,7 @@ class ReportViewController: UIViewController, NeedsDependency { return nil } let request = MastodonUser.sortedFetchRequest - request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.userId) + request.predicate = MastodonUser.predicate(domain: domain, id: viewModel.user.id) request.fetchLimit = 1 request.returnsObjectsAsFaults = false do { diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index dcb715fba..da22a4b95 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -22,6 +22,7 @@ extension ReportViewModel { context.apiService.userTimeline( domain: domain, accountID: accountId, + excludeReblogs: true, authorizationBox: authorizationBox ) .receive(on: DispatchQueue.main) @@ -30,7 +31,7 @@ extension ReportViewModel { case .failure(let error): os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch user timeline fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) guard let self = self else { return } - guard let reportStatusId = self.statusId else { return } + guard let reportStatusId = self.status?.id else { return } var statusIDs = self.statusFetchedResultsController.statusIDs.value guard statusIDs.contains(reportStatusId) else { return } @@ -44,7 +45,7 @@ extension ReportViewModel { guard let self = self else { return } var statusIDs = response.value.map { $0.id } - if let reportStatusId = self.statusId, !statusIDs.contains(reportStatusId) { + if let reportStatusId = self.status?.id, !statusIDs.contains(reportStatusId) { statusIDs.append(reportStatusId) } @@ -86,7 +87,7 @@ extension ReportViewModel { guard let status = managedObjectContext.object(with: objectID) as? Status else { continue } - if status.id == self.statusId { + if status.id == self.status?.id { attribute.isSelected = true self.append(statusID: status.id) self.continueEnableSubject.send(true) diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index 4864145f5..b787cf6c7 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -23,8 +23,8 @@ class ReportViewModel: NSObject { // confirm set only once weak var context: AppContext! { willSet { precondition(context == nil) } } - var userId: String - var statusId: String? + var user: MastodonUser + var status: Status? var statusIDs = [Mastodon.Entity.Status.ID]() var comment: String? @@ -56,12 +56,12 @@ class ReportViewModel: NSObject { init(context: AppContext, domain: String, - userId: String, - statusId: String? + user: MastodonUser, + status: Status? ) { self.context = context - self.userId = userId - self.statusId = statusId + self.user = user + self.status = status self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: domain, @@ -69,7 +69,7 @@ class ReportViewModel: NSObject { ) self.reportQuery = FileReportQuery( - accountID: userId, + accountID: user.id, statusIDs: [], comment: nil, forward: nil @@ -97,7 +97,7 @@ class ReportViewModel: NSObject { requestRecentStatus( domain: domain, - accountId: self.userId, + accountId: self.user.id, authorizationBox: activeMastodonAuthenticationBox ) From cbd598739e291ab1d852407ec1bedbebe10f9f08 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 26 Apr 2021 16:57:50 +0800 Subject: [PATCH 322/400] feat: make push notification trigger update when change setting --- .../CoreData.xcdatamodel/contents | 47 +- CoreDataStack/CoreDataStack.swift | 2 +- CoreDataStack/Entity/Setting.swift | 55 +- CoreDataStack/Entity/Subscription.swift | 76 ++- CoreDataStack/Entity/SubscriptionAlerts.swift | 168 ++++--- Mastodon.xcodeproj/project.pbxproj | 58 ++- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Coordinator/SceneCoordinator.swift | 12 +- .../SettingFetchedResultController.swift | 64 +++ Mastodon/Diffiable/Item/SettingsItem.swift | 67 +++ .../Diffiable/Section/SettingsSection.swift | 24 + .../Extension/CoreDataStack/Setting.swift | 24 + .../CoreDataStack/Subscription.swift | 20 + .../CoreDataStack/SubscriptionAlerts.swift | 28 ++ .../Mastodon+API+Subscriptions+Policy.swift | 20 + Mastodon/Extension/UserDefaults.swift | 31 ++ .../Preference/AppearancePreference.swift | 20 + ...meTimelineViewController+DebugAction.swift | 4 +- .../HomeTimelineViewController.swift | 14 +- .../Scene/Profile/ProfileViewController.swift | 4 +- .../Settings/SettingsViewController.swift | 298 +++++------ .../Scene/Settings/SettingsViewModel.swift | 473 +++++------------- .../SettingsAppearanceTableViewCell.swift | 17 +- .../View/Cell/SettingsLinkTableViewCell.swift | 14 +- .../Cell/SettingsToggleTableViewCell.swift | 56 ++- .../View/Content/TimelineHeaderView.swift | 2 + .../APIService/APIService+Subscriptions.swift | 153 ++---- .../APIService+CoreData+Setting.swift | 61 +++ .../APIService+CoreData+Subscriptions.swift | 101 +--- Mastodon/Service/AuthenticationService.swift | 55 +- Mastodon/Service/NotificationService.swift | 184 ++----- Mastodon/Service/SettingService.swift | 162 ++++++ Mastodon/State/AppContext.swift | 11 +- Mastodon/Supporting Files/AppSharedName.swift | 12 + Mastodon/Supporting Files/SceneDelegate.swift | 10 +- .../MastodonSDK/API/Mastodon+API+Push.swift | 79 ++- .../MastodonSDK/API/Mastodon+API.swift | 8 + .../Entity/Mastodon+Entity+Subscription.swift | 35 +- .../Sources/MastodonSDK/Query/Query.swift | 2 + 39 files changed, 1356 insertions(+), 1119 deletions(-) create mode 100644 Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift create mode 100644 Mastodon/Diffiable/Item/SettingsItem.swift create mode 100644 Mastodon/Diffiable/Section/SettingsSection.swift create mode 100644 Mastodon/Extension/CoreDataStack/Setting.swift create mode 100644 Mastodon/Extension/CoreDataStack/Subscription.swift create mode 100644 Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift create mode 100644 Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift create mode 100644 Mastodon/Extension/UserDefaults.swift create mode 100644 Mastodon/Preference/AppearancePreference.swift create mode 100644 Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift create mode 100644 Mastodon/Service/SettingService.swift create mode 100644 Mastodon/Supporting Files/AppSharedName.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 0d0170282..69c30e990 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -172,14 +172,12 @@ - - - - - - - - + + + + + + @@ -221,24 +219,27 @@ - + + + - - - - + + + + - - - - - + + + + + + - + @@ -263,10 +264,10 @@ - + - - + + - + \ No newline at end of file diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 1d13ee5ee..766bcf4de 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -18,7 +18,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: "group.org.joinmastodon.mastodon-temp", databaseName: databaseName) + let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/CoreDataStack/Entity/Setting.swift b/CoreDataStack/Entity/Setting.swift index 671f9bab3..6fac8c351 100644 --- a/CoreDataStack/Entity/Setting.swift +++ b/CoreDataStack/Entity/Setting.swift @@ -9,66 +9,61 @@ import CoreData import Foundation public final class Setting: NSManagedObject { - @NSManaged public var appearance: String? - @NSManaged public var triggerBy: String? - @NSManaged public var domain: String? - @NSManaged public var userID: String? + + @NSManaged public var appearanceRaw: String + @NSManaged public var domain: String + @NSManaged public var userID: String @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // relationships - @NSManaged public var subscription: Set? + // one-to-many relationships + @NSManaged public var subscriptions: Set? } -public extension Setting { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Setting.createdAt)) - } +extension Setting { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Setting.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Setting.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, property: Property ) -> Setting { let setting: Setting = context.insertObject() - setting.appearance = property.appearance - setting.triggerBy = property.triggerBy + setting.appearanceRaw = property.appearanceRaw setting.domain = property.domain setting.userID = property.userID return setting } - func update(appearance: String?) { - guard appearance != self.appearance else { return } - self.appearance = appearance + public func update(appearanceRaw: String) { + guard appearanceRaw != self.appearanceRaw else { return } + self.appearanceRaw = appearanceRaw didUpdate(at: Date()) } - func update(triggerBy: String?) { - guard triggerBy != self.triggerBy else { return } - self.triggerBy = triggerBy - didUpdate(at: Date()) + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate } + } -public extension Setting { - struct Property { - public let appearance: String - public let triggerBy: String +extension Setting { + public struct Property { public let domain: String public let userID: String + public let appearanceRaw: String - public init(appearance: String, triggerBy: String, domain: String, userID: String) { - self.appearance = appearance - self.triggerBy = triggerBy + public init(domain: String, userID: String, appearanceRaw: String) { self.domain = domain self.userID = userID + self.appearanceRaw = appearanceRaw } } } diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 8ced945d9..6cb1902a1 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -10,30 +10,35 @@ import Foundation import CoreData public final class Subscription: NSManagedObject { - @NSManaged public var id: String - @NSManaged public var endpoint: String - @NSManaged public var serverKey: String - /// four types: - /// - anyone - /// - a follower - /// - anyone I follow - /// - no one - @NSManaged public var type: String + @NSManaged public var id: String? + @NSManaged public var endpoint: String? + @NSManaged public var policyRaw: String + @NSManaged public var serverKey: String? + @NSManaged public var userToken: String? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date + @NSManaged public private(set) var activedAt: Date + + // MARK: one-to-one relationships + @NSManaged public var alert: SubscriptionAlerts - // MARK: - relationships - @NSManaged public var alert: SubscriptionAlerts? - // MARK: holder + // MARK: many-to-one relationships @NSManaged public var setting: Setting? } public extension Subscription { override func awakeFromInsert() { super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(Subscription.createdAt)) + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(Subscription.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.updatedAt)) + setPrimitiveValue(now, forKey: #keyPath(Subscription.activedAt)) + } + + func update(activedAt: Date) { + self.activedAt = activedAt } func didUpdate(at networkDate: Date) { @@ -43,45 +48,22 @@ public extension Subscription { @discardableResult static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + setting: Setting ) -> Subscription { - let setting: Subscription = context.insertObject() - setting.id = property.id - setting.endpoint = property.endpoint - setting.serverKey = property.serverKey - setting.type = property.type - - return setting + let subscription: Subscription = context.insertObject() + subscription.policyRaw = property.policyRaw + subscription.setting = setting + return subscription } } public extension Subscription { struct Property { - public let endpoint: String - public let id: String - public let serverKey: String - public let type: String + public let policyRaw: String - public init(endpoint: String, id: String, serverKey: String, type: String) { - self.endpoint = endpoint - self.id = id - self.serverKey = serverKey - self.type = type - } - } - - func updateIfNeed(property: Property) { - if self.endpoint != property.endpoint { - self.endpoint = property.endpoint - } - if self.id != property.id { - self.id = property.id - } - if self.serverKey != property.serverKey { - self.serverKey = property.serverKey - } - if self.type != property.type { - self.type = property.type + public init(policyRaw: String) { + self.policyRaw = policyRaw } } } @@ -94,8 +76,8 @@ extension Subscription: Managed { extension Subscription { - public static func predicate(type: String) -> NSPredicate { - return NSPredicate(format: "%K == %@", #keyPath(Subscription.type), type) + public static func predicate(policyRaw: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) } } diff --git a/CoreDataStack/Entity/SubscriptionAlerts.swift b/CoreDataStack/Entity/SubscriptionAlerts.swift index f5abf4955..613d1caf7 100644 --- a/CoreDataStack/Entity/SubscriptionAlerts.swift +++ b/CoreDataStack/Entity/SubscriptionAlerts.swift @@ -10,117 +10,165 @@ import Foundation import CoreData public final class SubscriptionAlerts: NSManagedObject { - @NSManaged public var follow: NSNumber? - @NSManaged public var favourite: NSNumber? - @NSManaged public var reblog: NSNumber? - @NSManaged public var mention: NSNumber? - @NSManaged public var poll: NSNumber? + @NSManaged public var favouriteRaw: NSNumber? + @NSManaged public var followRaw: NSNumber? + @NSManaged public var followRequestRaw: NSNumber? + @NSManaged public var mentionRaw: NSNumber? + @NSManaged public var pollRaw: NSNumber? + @NSManaged public var reblogRaw: NSNumber? @NSManaged public private(set) var createdAt: Date @NSManaged public private(set) var updatedAt: Date - // MARK: - relationships - @NSManaged public var subscription: Subscription? + // MARK: one-to-one relationships + @NSManaged public var subscription: Subscription } -public extension SubscriptionAlerts { - override func awakeFromInsert() { - super.awakeFromInsert() - setPrimitiveValue(Date(), forKey: #keyPath(SubscriptionAlerts.createdAt)) - } +extension SubscriptionAlerts { - func didUpdate(at networkDate: Date) { - self.updatedAt = networkDate + public override func awakeFromInsert() { + super.awakeFromInsert() + let now = Date() + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.createdAt)) + setPrimitiveValue(now, forKey: #keyPath(SubscriptionAlerts.updatedAt)) } @discardableResult - static func insert( + public static func insert( into context: NSManagedObjectContext, - property: Property + property: Property, + subscription: Subscription ) -> SubscriptionAlerts { let alerts: SubscriptionAlerts = context.insertObject() - alerts.favourite = property.favourite - alerts.follow = property.follow - alerts.mention = property.mention - alerts.poll = property.poll - alerts.reblog = property.reblog + + alerts.favouriteRaw = property.favouriteRaw + alerts.followRaw = property.followRaw + alerts.followRequestRaw = property.followRequestRaw + alerts.mentionRaw = property.mentionRaw + alerts.pollRaw = property.pollRaw + alerts.reblogRaw = property.reblogRaw + + alerts.subscription = subscription + return alerts } - func update(favourite: NSNumber?) { + public func update(favourite: Bool?) { guard self.favourite != favourite else { return } self.favourite = favourite didUpdate(at: Date()) } - func update(follow: NSNumber?) { + public func update(follow: Bool?) { guard self.follow != follow else { return } self.follow = follow didUpdate(at: Date()) } - func update(mention: NSNumber?) { + public func update(followRequest: Bool?) { + guard self.followRequest != followRequest else { return } + self.followRequest = followRequest + + didUpdate(at: Date()) + } + + public func update(mention: Bool?) { guard self.mention != mention else { return } self.mention = mention didUpdate(at: Date()) } - func update(poll: NSNumber?) { + public func update(poll: Bool?) { guard self.poll != poll else { return } self.poll = poll didUpdate(at: Date()) } - func update(reblog: NSNumber?) { + public func update(reblog: Bool?) { guard self.reblog != reblog else { return } self.reblog = reblog didUpdate(at: Date()) } + + public func didUpdate(at networkDate: Date) { + self.updatedAt = networkDate + } + } -public extension SubscriptionAlerts { - struct Property { - public let favourite: NSNumber? - public let follow: NSNumber? - public let mention: NSNumber? - public let poll: NSNumber? - public let reblog: NSNumber? +extension SubscriptionAlerts { + + private func boolean(from number: NSNumber?) -> Bool? { + return number.flatMap { $0.intValue == 1 } + } + + private func number(from boolean: Bool?) -> NSNumber? { + return boolean.flatMap { NSNumber(integerLiteral: $0 ? 1 : 0) } + } + + public var favourite: Bool? { + get { boolean(from: favouriteRaw) } + set { favouriteRaw = number(from: newValue) } + } + + public var follow: Bool? { + get { boolean(from: followRaw) } + set { followRaw = number(from: newValue) } + } + + public var followRequest: Bool? { + get { boolean(from: followRequestRaw) } + set { followRequestRaw = number(from: newValue) } + } + + public var mention: Bool? { + get { boolean(from: mentionRaw) } + set { mentionRaw = number(from: newValue) } + } + + public var poll: Bool? { + get { boolean(from: pollRaw) } + set { pollRaw = number(from: newValue) } + } + + public var reblog: Bool? { + get { boolean(from: reblogRaw) } + set { reblogRaw = number(from: newValue) } + } + +} - public init(favourite: NSNumber?, follow: NSNumber?, mention: NSNumber?, poll: NSNumber?, reblog: NSNumber?) { - self.favourite = favourite - self.follow = follow - self.mention = mention - self.poll = poll - self.reblog = reblog +extension SubscriptionAlerts { + public struct Property { + public let favouriteRaw: NSNumber? + public let followRaw: NSNumber? + public let followRequestRaw: NSNumber? + public let mentionRaw: NSNumber? + public let pollRaw: NSNumber? + public let reblogRaw: NSNumber? + + public init( + favourite: Bool?, + follow: Bool?, + followRequest: Bool?, + mention: Bool?, + poll: Bool?, + reblog: Bool? + ) { + self.favouriteRaw = favourite.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRaw = follow.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.followRequestRaw = followRequest.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.mentionRaw = mention.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.pollRaw = poll.flatMap { NSNumber(value: $0 ? 1 : 0) } + self.reblogRaw = reblog.flatMap { NSNumber(value: $0 ? 1 : 0) } } } - func updateIfNeed(property: Property) { - if self.follow != property.follow { - self.follow = property.follow - } - - if self.favourite != property.favourite { - self.favourite = property.favourite - } - - if self.reblog != property.reblog { - self.reblog = property.reblog - } - - if self.mention != property.mention { - self.mention = property.mention - } - - if self.poll != property.poll { - self.poll = property.poll - } - } } extension SubscriptionAlerts: Managed { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 30202e558..b1bc63e6d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -138,7 +138,6 @@ 5B90C460262599800002E742 /* SettingsAppearanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */; }; 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */; }; 5B90C462262599800002E742 /* SettingsSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */; }; - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C45D262599800002E742 /* SettingsViewController.swift */; }; 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46C26259B2C0002E742 /* Subscription.swift */; }; 5B90C46F26259B2C0002E742 /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C46D26259B2C0002E742 /* Setting.swift */; }; 5B90C47F26259BA90002E742 /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */; }; @@ -250,10 +249,24 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */; }; DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */; }; + DB6D9F6326357848008423CD /* SettingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6226357848008423CD /* SettingService.swift */; }; + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F6E2635807F008423CD /* Setting.swift */; }; + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */; }; + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F7C26358ED4008423CD /* SettingsSection.swift */; }; + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F8326358EEC008423CD /* SettingsItem.swift */; }; + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F9626367249008423CD /* SettingsViewController.swift */; }; DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */; }; DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */; }; DB71FD3C25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */; }; @@ -584,7 +597,6 @@ 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; 5B90C45B262599800002E742 /* SettingsLinkTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLinkTableViewCell.swift; sourceTree = ""; }; 5B90C45C262599800002E742 /* SettingsSectionHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsSectionHeader.swift; sourceTree = ""; }; - 5B90C45D262599800002E742 /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 5B90C46C26259B2C0002E742 /* Subscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; 5B90C46D26259B2C0002E742 /* Setting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; 5B90C47E26259BA90002E742 /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; @@ -703,8 +715,21 @@ DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonAttachmentService.swift; sourceTree = ""; }; DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; + DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = ""; }; + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; + DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+CoreData+Setting.swift"; sourceTree = ""; }; + DB6D9F6226357848008423CD /* SettingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingService.swift; sourceTree = ""; }; + DB6D9F6E2635807F008423CD /* Setting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Setting.swift; sourceTree = ""; }; + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingFetchedResultController.swift; sourceTree = ""; }; + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSection.swift; sourceTree = ""; }; + DB6D9F8326358EEC008423CD /* SettingsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsItem.swift; sourceTree = ""; }; + DB6D9F9626367249008423CD /* SettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; DB71FD2B25F86A5100512AE1 /* AvatarStackContainerButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarStackContainerButton.swift; sourceTree = ""; }; DB71FD3525F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistMemo.swift"; sourceTree = ""; }; DB71FD3B25F8A1C500512AE1 /* APIService+Persist+PersistCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Persist+PersistCache.swift"; sourceTree = ""; }; @@ -1109,6 +1134,7 @@ DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, + DB6D9F6226357848008423CD /* SettingService.swift */, ); path = Service; sourceTree = ""; @@ -1176,6 +1202,7 @@ 2D198648261C0B8500F0B013 /* SearchResultSection.swift */, DB66729525F9F91600D60309 /* ComposeStatusSection.swift */, DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */, + DB6D9F7C26358ED4008423CD /* SettingsSection.swift */, ); path = Section; sourceTree = ""; @@ -1232,6 +1259,7 @@ DB1FD45925F27898004CFCFC /* CategoryPickerItem.swift */, DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */, DB44768A260B3F2100B66B82 /* CustomEmojiPickerItem.swift */, + DB6D9F8326358EEC008423CD /* SettingsItem.swift */, ); path = Item; sourceTree = ""; @@ -1285,8 +1313,8 @@ isa = PBXGroup; children = ( 5B90C457262599800002E742 /* View */, + DB6D9F9626367249008423CD /* SettingsViewController.swift */, 5B90C456262599800002E742 /* SettingsViewModel.swift */, - 5B90C45D262599800002E742 /* SettingsViewController.swift */, ); path = Settings; sourceTree = ""; @@ -1350,6 +1378,9 @@ DB084B5625CBC56C00F898ED /* Status.swift */, DB45FAE225CA7181005A8AC7 /* MastodonUser.swift */, DB9D6C3725E508BE0051B173 /* Attachment.swift */, + DB6D9F6E2635807F008423CD /* Setting.swift */, + DB6D9F4826353FD6008423CD /* Subscription.swift */, + DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -1377,6 +1408,7 @@ DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, DB1E05E0263180F500201847 /* AppSecret.swift */, + DB6D1B2A2636852000ACB481 /* AppSharedName.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1511,6 +1543,7 @@ DB45FADC25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift */, DB45FAF825CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift */, 2D79E700261EA5550011E398 /* APIService+CoreData+Tag.swift */, + DB6D9F56263577D2008423CD /* APIService+CoreData+Setting.swift */, 5B90C48A26259C120002E742 /* APIService+CoreData+Subscriptions.swift */, ); path = CoreData; @@ -1530,6 +1563,7 @@ isa = PBXGroup; children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, + DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, ); path = Preference; sourceTree = ""; @@ -1573,6 +1607,7 @@ 5DDDF1A82617489F00311060 /* Mastodon+Entity+History.swift */, 2D650FAA25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift */, 2DB72C8B262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift */, + DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */, ); path = MastodonSDK; sourceTree = ""; @@ -1787,6 +1822,7 @@ 0F20223826146553000C64BF /* Array.swift */, DBCC3B2F261440A50045B23D /* UITabBarController.swift */, DBCC3B35261440BA0045B23D /* UINavigationController.swift */, + DB6D1B23263684C600ACB481 /* UserDefaults.swift */, ); path = Extension; sourceTree = ""; @@ -1994,6 +2030,7 @@ isa = PBXGroup; children = ( DBCBED1C26132E1A00B49291 /* StatusFetchedResultsController.swift */, + DB6D9F75263587C7008423CD /* SettingFetchedResultController.swift */, ); path = FetchedResultsController; sourceTree = ""; @@ -2555,6 +2592,7 @@ DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */, 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */, 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, + DB6D9F9726367249008423CD /* SettingsViewController.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, @@ -2566,8 +2604,8 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, - 5B90C463262599800002E742 /* SettingsViewController.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, + DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, @@ -2599,6 +2637,7 @@ 5B90C45F262599800002E742 /* SettingsToggleTableViewCell.swift in Sources */, 2D694A7425F9EB4E0038ADDC /* ContentWarningOverlayView.swift in Sources */, DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */, + DB6D9F76263587C7008423CD /* SettingFetchedResultController.swift in Sources */, DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */, 5D0393902612D259007FE196 /* WebViewController.swift in Sources */, DB4481CC25EE2AFE00BEFB67 /* PollItem.swift in Sources */, @@ -2624,9 +2663,11 @@ DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */, 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */, 2D084B8D26258EA3003AA3AF /* NotificationViewModel+diffable.swift in Sources */, + DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */, DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */, 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DB6D9F7D26358ED4008423CD /* SettingsSection.swift in Sources */, DB8AF55025C13703002E6C99 /* MainTabBarController.swift in Sources */, DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */, 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */, @@ -2645,6 +2686,7 @@ DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, 0FB3D33825E6401400AAD544 /* PickServerCell.swift in Sources */, + DB6D9F8426358EEC008423CD /* SettingsItem.swift in Sources */, 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */, DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, @@ -2664,6 +2706,7 @@ 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, + DB6D9F6326357848008423CD /* SettingService.swift in Sources */, 2D650FAB25ECDC9300851B58 /* Mastodon+Entity+Error+Detail.swift in Sources */, 2D24E12D2626FD2E00A59D4F /* NotificationViewModel+LoadOldestState.swift in Sources */, 2DB72C8C262D764300CE6173 /* Mastodon+Entity+Notification+Type.swift in Sources */, @@ -2678,11 +2721,13 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, + DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, DB9D6C0E25E4F9780051B173 /* MosaicImageViewContainer.swift in Sources */, DB71FD3625F8A16C00512AE1 /* APIService+Persist+PersistMemo.swift in Sources */, DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, + DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -2701,6 +2746,7 @@ 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, + DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, @@ -2734,17 +2780,20 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, + DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, 0FB3D30825E524C600AAD544 /* PickServerCategoriesCell.swift in Sources */, DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */, DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */, + DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */, DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */, 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, + DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, @@ -2794,6 +2843,7 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, + DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */, 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 41711ac91..3e5f0c5d3 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 13 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,7 +27,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 14 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index c2608fe83..9770aa9a4 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -61,6 +61,9 @@ extension SceneCoordinator { case profile(viewModel: ProfileViewModel) case favorite(viewModel: FavoriteViewModel) + // setting + case settings(viewModel: SettingsViewModel) + // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -68,7 +71,6 @@ extension SceneCoordinator { #if DEBUG case publicTimeline - case settings #endif var isOnboarding: Bool { @@ -246,6 +248,10 @@ private extension SceneCoordinator { let _viewController = FavoriteViewController() _viewController.viewModel = viewModel viewController = _viewController + case .settings(let viewModel): + let _viewController = SettingsViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { @@ -270,10 +276,6 @@ private extension SceneCoordinator { let _viewController = PublicTimelineViewController() _viewController.viewModel = PublicTimelineViewModel(context: appContext) viewController = _viewController - case .settings: - let _viewController = SettingsViewController() - _viewController.viewModel = SettingsViewModel(context: appContext, coordinator: self) - viewController = _viewController #endif } diff --git a/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift new file mode 100644 index 000000000..52eafc6b6 --- /dev/null +++ b/Mastodon/Diffiable/FetchedResultsController/SettingFetchedResultController.swift @@ -0,0 +1,64 @@ +// +// SettingFetchedResultController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import os.log +import UIKit +import Combine +import CoreData +import CoreDataStack +import MastodonSDK + +final class SettingFetchedResultController: NSObject { + + var disposeBag = Set() + + let fetchedResultsController: NSFetchedResultsController + + // input + + // output + let settings = CurrentValueSubject<[Setting], Never>([]) + + init(managedObjectContext: NSManagedObjectContext, additionalPredicate: NSPredicate?) { + self.fetchedResultsController = { + let fetchRequest = Setting.sortedFetchRequest + fetchRequest.returnsObjectsAsFaults = false + if let additionalPredicate = additionalPredicate { + fetchRequest.predicate = additionalPredicate + } + fetchRequest.fetchBatchSize = 20 + let controller = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: managedObjectContext, + sectionNameKeyPath: nil, + cacheName: nil + ) + + return controller + }() + super.init() + + fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + assertionFailure(error.localizedDescription) + } + } + +} + +// MARK: - NSFetchedResultsControllerDelegate +extension SettingFetchedResultController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let objects = fetchedResultsController.fetchedObjects ?? [] + self.settings.value = objects + } +} diff --git a/Mastodon/Diffiable/Item/SettingsItem.swift b/Mastodon/Diffiable/Item/SettingsItem.swift new file mode 100644 index 000000000..8aabdc741 --- /dev/null +++ b/Mastodon/Diffiable/Item/SettingsItem.swift @@ -0,0 +1,67 @@ +// +// SettingsItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import CoreData + +enum SettingsItem: Hashable { + case apperance(settingObjectID: NSManagedObjectID) + case notification(settingObjectID: NSManagedObjectID, switchMode: NotificationSwitchMode) + case boringZone(item: Link) + case spicyZone(item: Link) +} + +extension SettingsItem { + + enum AppearanceMode: String { + case automatic + case light + case dark + } + + enum NotificationSwitchMode: CaseIterable { + case favorite + case follow + case reblog + case mention + + var title: String { + switch self { + case .favorite: return L10n.Scene.Settings.Section.Notifications.favorites + case .follow: return L10n.Scene.Settings.Section.Notifications.follows + case .reblog: return L10n.Scene.Settings.Section.Notifications.boosts + case .mention: return L10n.Scene.Settings.Section.Notifications.mentions + } + } + } + + enum Link: CaseIterable { + case termsOfService + case privacyPolicy + case clearMediaCache + case signOut + + var title: String { + switch self { + case .termsOfService: return L10n.Scene.Settings.Section.Boringzone.terms + case .privacyPolicy: return L10n.Scene.Settings.Section.Boringzone.privacy + case .clearMediaCache: return L10n.Scene.Settings.Section.Spicyzone.clear + case .signOut: return L10n.Scene.Settings.Section.Spicyzone.signout + } + } + + var textColor: UIColor { + switch self { + case .termsOfService: return .systemBlue + case .privacyPolicy: return .systemBlue + case .clearMediaCache: return .systemRed + case .signOut: return .systemRed + } + } + } + +} diff --git a/Mastodon/Diffiable/Section/SettingsSection.swift b/Mastodon/Diffiable/Section/SettingsSection.swift new file mode 100644 index 000000000..7ec78a2ed --- /dev/null +++ b/Mastodon/Diffiable/Section/SettingsSection.swift @@ -0,0 +1,24 @@ +// +// SettingsSection.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation + +enum SettingsSection: Hashable { + case apperance + case notifications + case boringZone + case spicyZone + + var title: String { + switch self { + case .apperance: return L10n.Scene.Settings.Section.Appearance.title + case .notifications: return L10n.Scene.Settings.Section.Notifications.title + case .boringZone: return L10n.Scene.Settings.Section.Boringzone.title + case .spicyZone: return L10n.Scene.Settings.Section.Spicyzone.title + } + } +} diff --git a/Mastodon/Extension/CoreDataStack/Setting.swift b/Mastodon/Extension/CoreDataStack/Setting.swift new file mode 100644 index 000000000..b995b80e3 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Setting.swift @@ -0,0 +1,24 @@ +// +// Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +extension Setting { + + var appearance: SettingsItem.AppearanceMode { + return SettingsItem.AppearanceMode(rawValue: appearanceRaw) ?? .automatic + } + + var activeSubscription: Subscription? { + return (subscriptions ?? Set()) + .sorted(by: { $0.activedAt > $1.activedAt }) + .first + } + +} diff --git a/Mastodon/Extension/CoreDataStack/Subscription.swift b/Mastodon/Extension/CoreDataStack/Subscription.swift new file mode 100644 index 000000000..8253264a0 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Subscription.swift @@ -0,0 +1,20 @@ +// +// Subscription.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import Foundation +import CoreDataStack +import MastodonSDK + +typealias NotificationSubscription = Subscription + +extension Subscription { + + var policy: Mastodon.API.Subscriptions.Policy { + return Mastodon.API.Subscriptions.Policy(rawValue: policyRaw) ?? .all + } + +} diff --git a/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift new file mode 100644 index 000000000..edf2df0c9 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/SubscriptionAlerts.swift @@ -0,0 +1,28 @@ +// +// SubscriptionAlerts.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import Foundation +import CoreDataStack +import MastodonSDK + +extension SubscriptionAlerts.Property { + + init(policy: Mastodon.API.Subscriptions.Policy) { + switch policy { + case .all: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .follower: + self.init(favourite: true, follow: nil, followRequest: nil, mention: true, poll: true, reblog: true) + case .followed: + self.init(favourite: true, follow: true, followRequest: true, mention: true, poll: true, reblog: true) + case .none, ._other: + self.init(favourite: nil, follow: nil, followRequest: nil, mention: nil, poll: nil, reblog: nil) + } + } + +} diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift new file mode 100644 index 000000000..24bbfdace --- /dev/null +++ b/Mastodon/Extension/MastodonSDK/Mastodon+API+Subscriptions+Policy.swift @@ -0,0 +1,20 @@ +// +// Mastodon+API+Subscriptions+Policy.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation +import MastodonSDK + +extension Mastodon.API.Subscriptions.Policy { + var title: String { + switch self { + case .all: return L10n.Scene.Settings.Section.Notifications.Trigger.anyone + case .follower: return L10n.Scene.Settings.Section.Notifications.Trigger.follower + case .followed: return L10n.Scene.Settings.Section.Notifications.Trigger.follow + case .none, ._other: return L10n.Scene.Settings.Section.Notifications.Trigger.noone + } + } +} diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift new file mode 100644 index 000000000..5e067bbe9 --- /dev/null +++ b/Mastodon/Extension/UserDefaults.swift @@ -0,0 +1,31 @@ +// +// UserDefaults.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +extension UserDefaults { + static let shared = UserDefaults(suiteName: AppSharedName.groupID)! +} + +extension UserDefaults { + + subscript(key: String) -> T? { + get { + if let rawValue = value(forKey: key) as? T.RawValue { + return T(rawValue: rawValue) + } + return nil + } + set { set(newValue?.rawValue, forKey: key) } + } + + subscript(key: String) -> T? { + get { return value(forKey: key) as? T } + set { set(newValue, forKey: key) } + } + +} diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift new file mode 100644 index 000000000..8f2818c39 --- /dev/null +++ b/Mastodon/Preference/AppearancePreference.swift @@ -0,0 +1,20 @@ +// +// AppearancePreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var customUserInterfaceStyle: UIUserInterfaceStyle { + get { + register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) + return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified + } + set { UserDefaults.shared[#function] = newValue.rawValue } + } + +} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift index 8bbf9436e..63f76152f 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift @@ -328,7 +328,9 @@ extension HomeTimelineViewController { } @objc private func showSettings(_ sender: UIAction) { - coordinator.present(scene: .settings, from: self, transition: .modal(animated: true, completion: nil)) + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } } #endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 53909b2df..93e632f77 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -88,14 +88,8 @@ extension HomeTimelineViewController { // long press to trigger debug menu settingBarButtonItem.menu = debugMenu #else - // settingBarButtonItem.target = self - // settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) - settingBarButtonItem.menu = UIMenu(title: "Settings", image: nil, identifier: nil, options: .displayInline, children: [ - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ]) + settingBarButtonItem.target = self + settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) #endif navigationItem.rightBarButtonItem = composeBarButtonItem @@ -220,7 +214,9 @@ extension HomeTimelineViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func composeBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 8fc915a0a..e4be1eb1f 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -517,7 +517,9 @@ extension ProfileViewController { @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - + guard let setting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: setting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index b9ded67d0..aeed943eb 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -11,11 +11,10 @@ import Combine import ActiveLabel import CoreData import CoreDataStack +import MastodonSDK import AlamofireImage import Kingfisher -// iTODO: when to ask permission to Use Notifications - class SettingsViewController: UIViewController, NeedsDependency { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +22,7 @@ class SettingsViewController: UIViewController, NeedsDependency { var viewModel: SettingsViewModel! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() + var notificationPolicySubscription: AnyCancellable? var triggerMenu: UIMenu { let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone @@ -35,23 +35,23 @@ class SettingsViewController: UIViewController, NeedsDependency { options: .displayInline, children: [ UIAction(title: anyone, image: UIImage(systemName: "person.3"), attributes: []) { [weak self] action in - self?.updateTrigger(by: anyone) + self?.updateTrigger(policy: .all) }, UIAction(title: follower, image: UIImage(systemName: "person.crop.circle.badge.plus"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follower) + self?.updateTrigger(policy: .follower) }, UIAction(title: follow, image: UIImage(systemName: "person.crop.circle.badge.checkmark"), attributes: []) { [weak self] action in - self?.updateTrigger(by: follow) + self?.updateTrigger(policy: .followed) }, UIAction(title: noOne, image: UIImage(systemName: "nosign"), attributes: []) { [weak self] action in - self?.updateTrigger(by: noOne) + self?.updateTrigger(policy: .none) }, ] ) return menu } - lazy var notifySectionHeader: UIView = { + private(set) lazy var notifySectionHeader: UIView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true @@ -71,15 +71,12 @@ class SettingsViewController: UIViewController, NeedsDependency { return view }() - lazy var whoButton: UIButton = { + private(set) lazy var whoButton: UIButton = { let whoButton = UIButton(type: .roundedRect) whoButton.menu = triggerMenu whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) whoButton.layer.cornerRadius = 10 @@ -87,7 +84,7 @@ class SettingsViewController: UIViewController, NeedsDependency { return whoButton }() - lazy var tableView: UITableView = { + private(set) lazy var tableView: UITableView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Width' UIStackView:0x7f8c2b6c0590.width == 0) let tableView = UITableView(frame: CGRect(x: 0, y: 0, width: 320, height: 320), style: .grouped) tableView.translatesAutoresizingMaskIntoConstraints = false @@ -95,13 +92,13 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.rowHeight = UITableView.automaticDimension tableView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color - tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: "SettingsAppearanceTableViewCell") - tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: "SettingsToggleTableViewCell") - tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: "SettingsLinkTableViewCell") + tableView.register(SettingsAppearanceTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsAppearanceTableViewCell.self)) + tableView.register(SettingsToggleTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsToggleTableViewCell.self)) + tableView.register(SettingsLinkTableViewCell.self, forCellReuseIdentifier: String(describing: SettingsLinkTableViewCell.self)) return tableView }() - lazy var footerView: UIView = { + lazy var tableFooterView: UIView = { // init with a frame to fix a conflict ('UIView-Encapsulated-Layout-Height' UIStackView:0x7ffe41e47da0.height == 0) let view = UIStackView(frame: CGRect(x: 0, y: 0, width: 320, height: 320)) view.isLayoutMarginsRelativeArrangement = true @@ -143,14 +140,30 @@ class SettingsViewController: UIViewController, NeedsDependency { // MAKR: - Private methods private func bindViewModel() { - let input = SettingsViewModel.Input() - _ = viewModel.transform(input: input) + self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) + viewModel.setting + .sink { [weak self] setting in + guard let self = self else { return } + self.notificationPolicySubscription = ManagedObjectObserver.observe(object: setting) + .sink { _ in + // do nothing + } receiveValue: { [weak self] change in + guard let self = self else { return } + guard case let .update(object) = change.changeType, + let setting = object as? Setting else { return } + if let activeSubscription = setting.activeSubscription { + self.whoButton.setTitle(activeSubscription.policy.title, for: .normal) + } else { + assertionFailure() + } + } + } + .store(in: &disposeBag) } private func setupView() { view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color setupNavigation() - setupTableView() view.addSubview(tableView) NSLayoutConstraint.activate([ @@ -159,6 +172,7 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + setupTableView() } private func setupNavigation() { @@ -177,35 +191,12 @@ class SettingsViewController: UIViewController, NeedsDependency { } private func setupTableView() { - viewModel.dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { [weak self] (tableView, indexPath, item) -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .apperance(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAppearanceTableViewCell") as? SettingsAppearanceTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .notification(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsToggleTableViewCell") as? SettingsToggleTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item, delegate: self) - return cell - case .boringZone(let item), .spicyZone(let item): - guard let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsLinkTableViewCell") as? SettingsLinkTableViewCell else { - assertionFailure() - return nil - } - cell.update(with: item) - return cell - } - }) - - tableView.tableFooterView = footerView + viewModel.setupDiffableDataSource( + for: tableView, + settingsAppearanceTableViewCellDelegate: self, + settingsToggleCellDelegate: self + ) + tableView.tableFooterView = tableFooterView } func alertToSignout() { @@ -218,7 +209,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) let signOutAction = UIAlertAction(title: L10n.Common.Alerts.SignOut.confirm, style: .destructive) { [weak self] _ in guard let self = self else { return } - self.signout() + self.signOut() } alertController.addAction(cancelAction) alertController.addAction(signOutAction) @@ -229,7 +220,7 @@ class SettingsViewController: UIViewController, NeedsDependency { ) } - func signout() { + func signOut() { guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } @@ -258,8 +249,11 @@ class SettingsViewController: UIViewController, NeedsDependency { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } - // Mark: - Actions - @objc func doneButtonDidClick() { +} + +// Mark: - Actions +extension SettingsViewController { + @objc private func doneButtonDidClick() { dismiss(animated: true, completion: nil) } } @@ -268,51 +262,39 @@ extension SettingsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let sections = viewModel.dataSource.snapshot().sectionIdentifiers guard section < sections.count else { return nil } - let sectionData = sections[section] + + let sectionIdentifier = sections[section] let header: SettingsSectionHeader - if section == 1 { + switch sectionIdentifier { + case .notifications: header = SettingsSectionHeader( frame: CGRect(x: 0, y: 0, width: 375, height: 66), customView: notifySectionHeader) - header.update(title: sectionData.title) - - if let setting = self.viewModel.setting.value, let trigger = setting.triggerBy { - whoButton.setTitle(trigger, for: .normal) - } else { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - whoButton.setTitle(anyone, for: .normal) - } - } else { + header.update(title: sectionIdentifier.title) + default: header = SettingsSectionHeader(frame: CGRect(x: 0, y: 0, width: 375, height: 66)) - header.update(title: sectionData.title) + header.update(title: sectionIdentifier.title) } - header.preservesSuperviewLayoutMargins = true - + return header } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return UIView() } - + // remove the gap of table's footer func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 + return CGFloat.leastNonzeroMagnitude } - + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let snapshot = self.viewModel.dataSource.snapshot() - let sectionIds = snapshot.sectionIdentifiers - guard indexPath.section < sectionIds.count else { return } - let sectionIdentifier = sectionIds[indexPath.section] - let items = snapshot.itemIdentifiers(inSection: sectionIdentifier) - guard indexPath.row < items.count else { return } - let item = items[indexPath.item] - + guard let dataSource = viewModel.dataSource else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { case .boringZone: guard let url = viewModel.privacyURL else { break } @@ -331,7 +313,7 @@ extension SettingsViewController: UITableViewDelegate { ImageDownloader.defaultURLCache().removeAllCachedResponses() let cleanedDiskBytes = ImageDownloader.defaultURLCache().currentDiskUsage os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: diskBytes %d", ((#file as NSString).lastPathComponent), #line, #function, cleanedDiskBytes) - + // clean Kingfisher Cache KingfisherManager.shared.cache.clearDiskCache() } @@ -347,82 +329,77 @@ extension SettingsViewController: UITableViewDelegate { // Update setting into core data extension SettingsViewController { - func updateTrigger(by who: String) { - guard self.viewModel.triggerBy != who else { return } - guard let setting = self.viewModel.setting.value else { return } + func updateTrigger(policy: Mastodon.API.Subscriptions.Policy) { + let objectID = self.viewModel.setting.value.objectID + let managedObjectContext = context.backgroundManagedObjectContext - setting.update(triggerBy: who) - // trigger to call `subscription` API with POST method - // confirm the local data is correct even if request failed - // The asynchronous execution is to solve the problem of dropped frames for animations. - DispatchQueue.main.async { [weak self] in - self?.viewModel.setting.value = setting + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: objectID) as! Setting + let (subscription, _) = APIService.CoreData.createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + let now = Date() + subscription.update(activedAt: now) + setting.didUpdate(at: now) } - } - - func updateAlert(title: String?, isOn: Bool) { - guard let title = title else { return } - guard let settings = self.viewModel.setting.value else { return } - guard let triggerBy = settings.triggerBy else { return } - - if let alerts = settings.subscription?.first(where: { (s) -> Bool in - return s.type == settings.triggerBy - })?.alert { - var alertValues = [Bool?]() - alertValues.append(alerts.favourite?.boolValue) - alertValues.append(alerts.follow?.boolValue) - alertValues.append(alerts.reblog?.boolValue) - alertValues.append(alerts.mention?.boolValue) - - // need to update `alerts` to make update API with correct parameter - switch title { - case L10n.Scene.Settings.Section.Notifications.favorites: - alertValues[0] = isOn - alerts.favourite = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.follows: - alertValues[1] = isOn - alerts.follow = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.boosts: - alertValues[2] = isOn - alerts.reblog = NSNumber(booleanLiteral: isOn) - case L10n.Scene.Settings.Section.Notifications.mentions: - alertValues[3] = isOn - alerts.mention = NSNumber(booleanLiteral: isOn) - default: break - } - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) - } else if let alertValues = self.viewModel.notificationDefaultValue[triggerBy] { - self.viewModel.updateSubscriptionSubject.send((triggerBy: triggerBy, values: alertValues)) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nohting } + .store(in: &disposeBag) } } +// MARK: - SettingsAppearanceTableViewCellDelegate extension SettingsViewController: SettingsAppearanceTableViewCellDelegate { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) { - guard let setting = self.viewModel.setting.value else { return } - + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + guard case let .apperance(settingObjectID) = item else { return } + context.managedObjectContext.performChanges { - setting.update(appearance: didSelect.rawValue) + let setting = self.context.managedObjectContext.object(with: settingObjectID) as! Setting + setting.update(appearanceRaw: appearanceMode.rawValue) } - .sink { (_) in - // change light / dark mode - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - view.window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle + .sink { _ in + // do nothing }.store(in: &disposeBag) } } extension SettingsViewController: SettingsToggleCellDelegate { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) { - updateAlert(title: cell.data?.title, isOn: didChangeStatus) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) { + guard let dataSource = viewModel.dataSource else { return } + guard let indexPath = tableView.indexPath(for: cell) else { return } + let item = dataSource.itemIdentifier(for: indexPath) + switch item { + case .notification(let settingObjectID, let switchMode): + let isOn = `switch`.isOn + let managedObjectContext = context.backgroundManagedObjectContext + managedObjectContext.performChanges { + let setting = managedObjectContext.object(with: settingObjectID) as! Setting + guard let subscription = setting.activeSubscription else { return } + let alert = subscription.alert + switch switchMode { + case .favorite: alert.update(favourite: isOn) + case .follow: alert.update(follow: isOn) + case .reblog: alert.update(reblog: isOn) + case .mention: alert.update(mention: isOn) + } + // trigger setting update + alert.subscription.setting?.didUpdate(at: Date()) + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + default: + break + } } } @@ -436,43 +413,6 @@ extension SettingsViewController: ActiveLabelDelegate { } } -extension SettingsViewController { - static func updateOverrideUserInterfaceStyle(window: UIWindow?) { - guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - - guard let setting: Setting? = { - let domain = box.domain - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: box.userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try AppContext.shared.managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() else { return } - - guard let didSelect = SettingsItem.AppearanceMode(rawValue: setting?.appearance ?? "") else { - return - } - - var overrideUserInterfaceStyle: UIUserInterfaceStyle! - switch didSelect { - case .automatic: - overrideUserInterfaceStyle = .unspecified - case .light: - overrideUserInterfaceStyle = .light - case .dark: - overrideUserInterfaceStyle = .dark - } - window?.overrideUserInterfaceStyle = overrideUserInterfaceStyle - } -} - #if canImport(SwiftUI) && DEBUG import SwiftUI diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift b/Mastodon/Scene/Settings/SettingsViewModel.swift index f7ee4c71b..c168b5611 100644 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift @@ -13,38 +13,21 @@ import MastodonSDK import UIKit import os.log -class SettingsViewModel: NSObject, NeedsDependency { - // confirm set only once - weak var context: AppContext! { willSet { precondition(context == nil) } } - weak var coordinator: SceneCoordinator! { willSet { precondition(coordinator == nil) } } +class SettingsViewModel { - var dataSource: UITableViewDiffableDataSource! var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject var updateDisposeBag = Set() var createDisposeBag = Set() let viewDidLoad = PassthroughSubject() - lazy var fetchResultsController: NSFetchedResultsController = { - let fetchRequest = Setting.sortedFetchRequest - if let box = - self.context.authenticationService.activeMastodonAuthenticationBox.value { - let domain = box.domain - fetchRequest.predicate = Setting.predicate(domain: domain, userID: box.userID) - } - - fetchRequest.fetchLimit = 1 - fetchRequest.returnsObjectsAsFaults = false - let controller = NSFetchedResultsController( - fetchRequest: fetchRequest, - managedObjectContext: context.managedObjectContext, - sectionNameKeyPath: nil, - cacheName: nil - ) - controller.delegate = self - return controller - }() - let setting = CurrentValueSubject(nil) + // output + var dataSource: UITableViewDiffableDataSource! /// create a subscription when: /// - does not has one /// - does not find subscription for selected trigger when change trigger @@ -54,22 +37,6 @@ class SettingsViewModel: NSObject, NeedsDependency { /// - change switch for specified alerts let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() - lazy var notificationDefaultValue: [String: [Bool?]] = { - let followerSwitchItems: [Bool?] = [true, nil, true, true] - let anyoneSwitchItems: [Bool?] = [true, true, true, true] - let noOneSwitchItems: [Bool?] = [nil, nil, nil, nil] - let followSwitchItems: [Bool?] = [true, true, true, true] - - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - let follower = L10n.Scene.Settings.Section.Notifications.Trigger.follower - let follow = L10n.Scene.Settings.Section.Notifications.Trigger.follow - let noOne = L10n.Scene.Settings.Section.Notifications.Trigger.noone - return [anyone: anyoneSwitchItems, - follower: followerSwitchItems, - follow: followSwitchItems, - noOne: noOneSwitchItems] - }() - lazy var privacyURL: URL? = { guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { return nil @@ -78,321 +45,151 @@ class SettingsViewModel: NSObject, NeedsDependency { return Mastodon.API.privacyURL(domain: box.domain) }() - /// to store who trigger the notification. - var triggerBy: String? - - struct Input { - } - - struct Output { - } - - init(context: AppContext, coordinator: SceneCoordinator) { + init(context: AppContext, setting: Setting) { self.context = context - self.coordinator = coordinator + self.setting = CurrentValueSubject(setting) - super.init() - } - - func transform(input: Input?) -> Output? { - typealias SubscriptionResponse = Mastodon.Response.Content -// createSubscriptionSubject -// .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) -// .sink { _ in -// } receiveValue: { [weak self] (arg) in -// let (triggerBy, values) = arg -// guard let self = self else { -// return -// } -// guard let activeMastodonAuthenticationBox = -// self.context.authenticationService.activeMastodonAuthenticationBox.value else { -// return -// } -// guard values.count >= 4 else { -// return -// } -// -// self.createDisposeBag.removeAll() -// typealias Query = Mastodon.API.Subscriptions.CreateSubscriptionQuery -// let domain = activeMastodonAuthenticationBox.domain -// let query = Query( -// // FIXME: to replace the correct endpoint, p256dh, auth -// endpoint: "http://www.google.com", -// p256dh: "BLQELIDm-6b9Bl07YrEuXJ4BL_YBVQ0dvt9NQGGJxIQidJWHPNa9YrouvcQ9d7_MqzvGS9Alz60SZNCG3qfpk=", -// auth: "4vQK-SvRAN5eo-8ASlrwA==", -// favourite: values[0], -// follow: values[1], -// reblog: values[2], -// mention: values[3], -// poll: nil -// ) -// self.context.apiService.changeSubscription( -// domain: domain, -// mastodonAuthenticationBox: activeMastodonAuthenticationBox, -// query: query, -// triggerBy: triggerBy, -// userID: activeMastodonAuthenticationBox.userID -// ) -// .sink { (_) in -// } receiveValue: { (_) in -// } -// .store(in: &self.createDisposeBag) -// } -// .store(in: &disposeBag) - - updateSubscriptionSubject - .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) - .sink { _ in - } receiveValue: { [weak self] (arg) in - let (triggerBy, values) = arg - guard let self = self else { - return - } - guard let activeMastodonAuthenticationBox = - self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - guard values.count >= 4 else { - return - } - - self.updateDisposeBag.removeAll() - typealias Query = Mastodon.API.Subscriptions.UpdateSubscriptionQuery - let domain = activeMastodonAuthenticationBox.domain - let query = Query( - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - favourite: values[0], - follow: values[1], - reblog: values[2], - mention: values[3], - poll: nil - ) - ) - ) - self.context.apiService.updateSubscription( - domain: domain, - mastodonAuthenticationBox: activeMastodonAuthenticationBox, - query: query, - triggerBy: triggerBy, - userID: activeMastodonAuthenticationBox.userID - ) - .sink { (_) in - } receiveValue: { (_) in - } - .store(in: &self.updateDisposeBag) - } + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) .store(in: &disposeBag) - - // build data for table view - buildDataSource() - - // request subsription data for updating or initialization - requestSubscription() - return nil - } - - // MARK: - Private methods - fileprivate func processDataSource(_ settings: Setting?) { - var snapshot = NSDiffableDataSourceSnapshot() - - // appearance - let appearnceMode = SettingsItem.AppearanceMode(rawValue: settings?.appearance ?? "") ?? .automatic - let appearanceItem = SettingsItem.apperance(item: appearnceMode) - let appearance = SettingsSection.apperance(title: L10n.Scene.Settings.Section.Appearance.title, selectedMode:appearanceItem) - snapshot.appendSections([appearance]) - snapshot.appendItems([appearanceItem]) - - // notifications - var switches: [Bool?]? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - } - - let notifications = [L10n.Scene.Settings.Section.Notifications.favorites, - L10n.Scene.Settings.Section.Notifications.follows, - L10n.Scene.Settings.Section.Notifications.boosts, - L10n.Scene.Settings.Section.Notifications.mentions,] - var notificationItems = [SettingsItem]() - for (i, noti) in notifications.enumerated() { - var value: Bool? = nil - if let switches = switches, i < switches.count { - value = switches[i] - } - - let item = SettingsItem.notification(item: SettingsItem.NotificationSwitch(title: noti, isOn: value == true, enable: value != nil)) - notificationItems.append(item) - } - let notificationSection = SettingsSection.notifications(title: L10n.Scene.Settings.Section.Notifications.title, items: notificationItems) - snapshot.appendSections([notificationSection]) - snapshot.appendItems(notificationItems) - - // boring zone - let boringLinks = [L10n.Scene.Settings.Section.Boringzone.terms, - L10n.Scene.Settings.Section.Boringzone.privacy] - var boringLinkItems = [SettingsItem]() - for l in boringLinks { - let item = SettingsItem.boringZone(item: SettingsItem.Link(title: l, color: .systemBlue)) - boringLinkItems.append(item) - } - let boringSection = SettingsSection.boringZone(title: L10n.Scene.Settings.Section.Boringzone.title, items: boringLinkItems) - snapshot.appendSections([boringSection]) - snapshot.appendItems(boringLinkItems) - - // spicy zone - let spicyLinks = [L10n.Scene.Settings.Section.Spicyzone.clear, - L10n.Scene.Settings.Section.Spicyzone.signout] - var spicyLinkItems = [SettingsItem]() - for l in spicyLinks { - let item = SettingsItem.spicyZone(item: SettingsItem.Link(title: l, color: .systemRed)) - spicyLinkItems.append(item) - } - let spicySection = SettingsSection.spicyZone(title: L10n.Scene.Settings.Section.Spicyzone.title, items: spicyLinkItems) - snapshot.appendSections([spicySection]) - snapshot.appendItems(spicyLinkItems) - - self.dataSource.apply(snapshot, animatingDifferences: false) - } - - private func buildDataSource() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - self.processDataSource(settings) - } - .store(in: &disposeBag) - } - - private func requestSubscription() { - setting.sink { [weak self] (settings) in - guard let self = self else { return } - guard settings != nil else { return } - guard self.triggerBy != settings?.triggerBy else { return } - self.triggerBy = settings?.triggerBy - - var switches: [Bool?]? - var who: String? - if let alerts = settings?.subscription?.first(where: { (s) -> Bool in - return s.type == settings?.triggerBy - })?.alert { - var items = [Bool?]() - items.append(alerts.favourite?.boolValue) - items.append(alerts.follow?.boolValue) - items.append(alerts.reblog?.boolValue) - items.append(alerts.mention?.boolValue) - switches = items - who = settings?.triggerBy - } else if let triggerBy = settings?.triggerBy, - let values = self.notificationDefaultValue[triggerBy] { - switches = values - who = triggerBy - } else { - // fallback a default value - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - switches = self.notificationDefaultValue[anyone] - who = anyone - } - - // should create a subscription whenever change trigger - if let values = switches, let triggerBy = who { - self.createSubscriptionSubject.send((triggerBy: triggerBy, values: values)) - } - } - .store(in: &disposeBag) - - guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { - return - } - let domain = activeMastodonAuthenticationBox.domain - let userId = activeMastodonAuthenticationBox.userID - - do { - try fetchResultsController.performFetch() - if nil == fetchResultsController.fetchedObjects?.first { - let anyone = L10n.Scene.Settings.Section.Notifications.Trigger.anyone - setting.value = self.context.apiService.createSettingIfNeed(domain: domain, - userId: userId, - triggerBy: anyone) - } else { - setting.value = fetchResultsController.fetchedObjects?.first - } - } catch { - assertionFailure(error.localizedDescription) - } } deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) } + } -// MARK: - NSFetchedResultsControllerDelegate -extension SettingsViewModel: NSFetchedResultsControllerDelegate { +extension SettingsViewModel { - func controllerWillChangeContent(_ controller: NSFetchedResultsController) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - guard controller === fetchResultsController else { - return + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } } - setting.value = fetchResultsController.fetchedObjects?.first + processDataSource(self.setting.value) } - } -enum SettingsSection: Hashable { - case apperance(title: String, selectedMode: SettingsItem) - case notifications(title: String, items: [SettingsItem]) - case boringZone(title: String, items: [SettingsItem]) - case spicyZone(title: String, items: [SettingsItem]) +extension SettingsViewModel { - var title: String { - switch self { - case .apperance(let title, _), - .notifications(let title, _), - .boringZone(let title, _), - .spicyZone(let title, _): - return title + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention } + cell.update(enabled: enabled) } -} - -enum SettingsItem: Hashable { - enum AppearanceMode: String { - case automatic - case light - case dark - } - - struct NotificationSwitch: Hashable { - let title: String - let isOn: Bool - let enable: Bool - } - - struct Link: Hashable { - let title: String - let color: UIColor - } - - case apperance(item: AppearanceMode) - case notification(item: NotificationSwitch) - case boringZone(item: Link) - case spicyZone(item: Link) + } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index a477661ee..44a7e7574 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -6,9 +6,10 @@ // import UIKit +import Combine protocol SettingsAppearanceTableViewCellDelegate: class { - func settingsAppearanceCell(_ view: SettingsAppearanceTableViewCell, didSelect: SettingsItem.AppearanceMode) + func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) } class AppearanceView: UIView { @@ -85,6 +86,9 @@ class AppearanceView: UIView { } class SettingsAppearanceTableViewCell: UITableViewCell { + + var disposeBag = Set() + weak var delegate: SettingsAppearanceTableViewCellDelegate? var appearance: SettingsItem.AppearanceMode = .automatic @@ -123,6 +127,12 @@ class SettingsAppearanceTableViewCell: UITableViewCell { tapGestureRecognizer.addTarget(self, action: #selector(appearanceDidTap(sender:))) return tapGestureRecognizer }() + + override func prepareForReuse() { + super.prepareForReuse() + + disposeBag.removeAll() + } // MARK: - Methods override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -145,9 +155,8 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } } - func update(with data: SettingsItem.AppearanceMode, delegate: SettingsAppearanceTableViewCellDelegate?) { + func update(with data: SettingsItem.AppearanceMode) { appearance = data - self.delegate = delegate automatic.selected = false light.selected = false @@ -200,6 +209,6 @@ class SettingsAppearanceTableViewCell: UITableViewCell { } guard let delegate = self.delegate else { return } - delegate.settingsAppearanceCell(self, didSelect: appearance) + delegate.settingsAppearanceCell(self, didSelectAppearanceMode: appearance) } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift index b5d0306d4..7fdbf7f02 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsLinkTableViewCell.swift @@ -8,6 +8,7 @@ import UIKit class SettingsLinkTableViewCell: UITableViewCell { + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -22,10 +23,13 @@ class SettingsLinkTableViewCell: UITableViewCell { super.setHighlighted(highlighted, animated: animated) textLabel?.alpha = highlighted ? 0.6 : 1.0 } - - // MARK: - Methods - func update(with data: SettingsItem.Link) { - textLabel?.text = data.title - textLabel?.textColor = data.color + +} + +// MARK: - Methods +extension SettingsLinkTableViewCell { + func update(with link: SettingsItem.Link) { + textLabel?.text = link.title + textLabel?.textColor = link.textColor } } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index b35b2b50f..b4a62635b 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -6,18 +6,21 @@ // import UIKit +import Combine protocol SettingsToggleCellDelegate: class { - func settingsToggleCell(_ cell: SettingsToggleTableViewCell, didChangeStatus: Bool) + func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) } class SettingsToggleTableViewCell: UITableViewCell { - lazy var switchButton: UISwitch = { + + var disposeBag = Set() + + private(set) lazy var switchButton: UISwitch = { let view = UISwitch(frame:.zero) return view }() - var data: SettingsItem.NotificationSwitch? weak var delegate: SettingsToggleCellDelegate? // MARK: - Methods @@ -27,21 +30,8 @@ class SettingsToggleTableViewCell: UITableViewCell { } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(with data: SettingsItem.NotificationSwitch, delegate: SettingsToggleCellDelegate?) { - self.delegate = delegate - self.data = data - textLabel?.text = data.title - switchButton.isOn = data.isOn - setup(enable: data.enable) - } - - // MARK: Actions - @objc func valueDidChange(sender: UISwitch) { - guard let delegate = delegate else { return } - delegate.settingsToggleCell(self, didChangeStatus: sender.isOn) + super.init(coder: coder) + setupUI() } // MARK: Private methods @@ -49,15 +39,27 @@ class SettingsToggleTableViewCell: UITableViewCell { selectionStyle = .none accessoryView = switchButton - switchButton.addTarget(self, action: #selector(valueDidChange(sender:)), for: .valueChanged) + switchButton.addTarget(self, action: #selector(switchValueDidChange(sender:)), for: .valueChanged) + } + +} + +// MARK: - Actions +extension SettingsToggleTableViewCell { + + @objc private func switchValueDidChange(sender: UISwitch) { + guard let delegate = delegate else { return } + delegate.settingsToggleCell(self, switchValueDidChange: sender) + } + +} + +extension SettingsToggleTableViewCell { + + func update(enabled: Bool?) { + switchButton.isEnabled = enabled != nil + textLabel?.textColor = enabled != nil ? Asset.Colors.Label.primary.color : Asset.Colors.Label.secondary.color + switchButton.isOn = enabled ?? false } - private func setup(enable: Bool) { - if enable { - textLabel?.textColor = Asset.Colors.Label.primary.color - } else { - textLabel?.textColor = Asset.Colors.Label.secondary.color - } - switchButton.isEnabled = enable - } } diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index b5e4c5bde..f095f6f44 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -5,6 +5,8 @@ // Created by MainasuK Cirno on 2021-4-6. // +import UIKit + final class TimelineHeaderView: UIView { let iconImageView: UIImageView = { diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 5260452ce..3e2d2a0aa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -5,6 +5,7 @@ // Created by ihugo on 2021/4/9. // +import os.log import Combine import CoreData import CoreDataStack @@ -13,66 +14,14 @@ import MastodonSDK extension APIService { - func subscription( + func createSubscription( + subscriptionObjectID: NSManagedObjectID, + query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - let findSettings: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let triggerBy = findSettings?.triggerBy ?? "anyone" - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) - return Mastodon.API.Subscriptions.subscription( - session: session, - domain: domain, - authorization: authorization - ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func createSubscription( - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.CreateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { - let authorization = mastodonAuthenticationBox.userAuthorization - let domain = mastodonAuthenticationBox.domain - let userID = mastodonAuthenticationBox.userID - - let setting = self.createSettingIfNeed( - domain: domain, - userId: userID, - triggerBy: triggerBy - ) return Mastodon.API.Subscriptions.createSubscription( session: session, domain: domain, @@ -80,14 +29,18 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function) + + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + guard let subscription = managedObjectContext.object(with: subscriptionObjectID) as? NotificationSubscription else { + assertionFailure() + return + } + subscription.endpoint = response.value.endpoint + subscription.serverKey = response.value.serverKey + subscription.userToken = authorization.accessToken + subscription.didUpdate(at: response.networkDate) } .setFailureType(to: Error.self) .map { _ in return response } @@ -95,72 +48,22 @@ extension APIService { }.eraseToAnyPublisher() } - func updateSubscription( - domain: String, - mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - query: Mastodon.API.Subscriptions.UpdateSubscriptionQuery, - triggerBy: String, - userID: String - ) -> AnyPublisher, Error> { + func cancelSubscription( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { let authorization = mastodonAuthenticationBox.userAuthorization - - let setting = self.createSettingIfNeed(domain: domain, - userId: userID, - triggerBy: triggerBy) - - return Mastodon.API.Subscriptions.updateSubscription( + let domain = mastodonAuthenticationBox.domain + + return Mastodon.API.Subscriptions.removeSubscription( session: session, domain: domain, - authorization: authorization, - query: query + authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - _ = APIService.CoreData.createOrMergeSubscription( - into: self.backgroundManagedObjectContext, - entity: response.value, - domain: domain, - triggerBy: triggerBy, - setting: setting - ) - } - .setFailureType(to: Error.self) - .map { _ in return response } - .eraseToAnyPublisher() - }.eraseToAnyPublisher() - } - - func createSettingIfNeed(domain: String, userId: String, triggerBy: String) -> Setting { - // create setting entity if possible - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: domain, userID: userId) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - var setting: Setting! - if let oldSetting = oldSetting { - setting = oldSetting - } else { - let property = Setting.Property( - appearance: "automatic", - triggerBy: triggerBy, - domain: domain, - userID: userId) - (setting, _) = APIService.CoreData.createOrMergeSetting( - into: backgroundManagedObjectContext, - domain: domain, - userID: userId, - property: property - ) - } - return setting + .handleEvents(receiveOutput: { _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: cancel subscription successful", ((#file as NSString).lastPathComponent), #line, #function) + }) + .eraseToAnyPublisher() } + } diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift new file mode 100644 index 000000000..fb6879da9 --- /dev/null +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -0,0 +1,61 @@ +// +// APIService+CoreData+Setting.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonSDK + +extension APIService.CoreData { + + static func createOrMergeSetting( + into managedObjectContext: NSManagedObjectContext, + property: Setting.Property + ) -> (Subscription: Setting, isCreated: Bool) { + let oldSetting: Setting? = { + let request = Setting.sortedFetchRequest + request.predicate = Setting.predicate(domain: property.domain, userID: property.userID) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + return managedObjectContext.safeFetch(request).first + }() + + if let oldSetting = oldSetting { + return (oldSetting, false) + } else { + let setting = Setting.insert( + into: managedObjectContext, + property: property + ) + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + let now = Date() + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + + return (setting, true) + } + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index f5a4022ea..5e42a8abe 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -13,96 +13,49 @@ import MastodonSDK extension APIService.CoreData { - static func createOrMergeSetting( + static func createOrFetchSubscription( into managedObjectContext: NSManagedObjectContext, - domain: String, - userID: String, - property: Setting.Property - ) -> (Subscription: Setting, isCreated: Bool) { - let oldSetting: Setting? = { - let request = Setting.sortedFetchRequest - request.predicate = Setting.predicate(domain: property.domain, userID: userID) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - - if let oldSetting = oldSetting { - return (oldSetting, false) - } else { - let setting = Setting.insert( - into: managedObjectContext, - property: property) - return (setting, true) - } - } - - static func createOrMergeSubscription( - into managedObjectContext: NSManagedObjectContext, - entity: Mastodon.Entity.Subscription, - domain: String, - triggerBy: String, - setting: Setting - ) -> (Subscription: Subscription, isCreated: Bool) { + setting: Setting, + policy: Mastodon.API.Subscriptions.Policy + ) -> (subscription: Subscription, isCreated: Bool) { let oldSubscription: Subscription? = { let request = Subscription.sortedFetchRequest - request.predicate = Subscription.predicate(type: triggerBy) + request.predicate = Subscription.predicate(policyRaw: policy.rawValue) request.fetchLimit = 1 request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } + return managedObjectContext.safeFetch(request).first }() - let property = Subscription.Property( - endpoint: entity.endpoint, - id: entity.id, - serverKey: entity.serverKey, - type: triggerBy - ) - let alertEntity = entity.alerts - let alert = SubscriptionAlerts.Property( - favourite: alertEntity.favouriteNumber, - follow: alertEntity.followNumber, - mention: alertEntity.mentionNumber, - poll: alertEntity.pollNumber, - reblog: alertEntity.reblogNumber - ) if let oldSubscription = oldSubscription { - oldSubscription.updateIfNeed(property: property) - if nil == oldSubscription.alert { - oldSubscription.alert = SubscriptionAlerts.insert( - into: managedObjectContext, - property: alert - ) - } else { - oldSubscription.alert?.updateIfNeed(property: alert) - } - - if oldSubscription.alert?.hasChanges == true || oldSubscription.hasChanges { - // don't expand subscription if add existed subscription - //setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(oldSubscription) - oldSubscription.didUpdate(at: Date()) - } return (oldSubscription, false) } else { + let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) let subscription = Subscription.insert( into: managedObjectContext, - property: property + property: subscriptionProperty, + setting: setting ) + let alertProperty = SubscriptionAlerts.Property(policy: policy) subscription.alert = SubscriptionAlerts.insert( into: managedObjectContext, - property: alert) - setting.mutableSetValue(forKey: #keyPath(Setting.subscription)).add(subscription) + property: alertProperty, + subscription: subscription + ) + return (subscription, true) } } + +} + +extension APIService.CoreData { + + static func merge( + subscription: Subscription, + property: Subscription.Property, + networkDate: Date + ) { + // TODO: + } + } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index 89ce7a182..6b35486d6 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -15,6 +15,7 @@ import MastodonSDK final class AuthenticationService: NSObject { var disposeBag = Set() + // input weak var apiService: APIService? let managedObjectContext: NSManagedObjectContext // read-only @@ -23,6 +24,7 @@ final class AuthenticationService: NSObject { // output let mastodonAuthentications = CurrentValueSubject<[MastodonAuthentication], Never>([]) + let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) @@ -58,16 +60,24 @@ final class AuthenticationService: NSObject { .assign(to: \.value, on: activeMastodonAuthentication) .store(in: &disposeBag) - activeMastodonAuthentication - .map { authentication -> AuthenticationService.MastodonAuthenticationBox? in - guard let authentication = authentication else { return nil } - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) + mastodonAuthentications + .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in + return authentications + .sorted(by: { $0.activedAt > $1.activedAt }) + .compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in + return AuthenticationService.MastodonAuthenticationBox( + domain: authentication.domain, + userID: authentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) + ) + } } + .assign(to: \.value, on: mastodonAuthenticationBoxes) + .store(in: &disposeBag) + + mastodonAuthenticationBoxes + .map { $0.first } .assign(to: \.value, on: activeMastodonAuthenticationBox) .store(in: &disposeBag) @@ -114,16 +124,37 @@ extension AuthenticationService { func signOutMastodonUser(domain: String, userID: MastodonUser.ID) -> AnyPublisher, Never> { var isSignOut = false - return backgroundManagedObjectContext.performChanges { + var _mastodonAutenticationBox: MastodonAuthenticationBox? + let managedObjectContext = backgroundManagedObjectContext + return managedObjectContext.performChanges { let request = MastodonAuthentication.sortedFetchRequest request.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) request.fetchLimit = 1 - guard let mastodonAutentication = try? self.backgroundManagedObjectContext.fetch(request).first else { + guard let mastodonAutentication = try? managedObjectContext.fetch(request).first else { return } - self.backgroundManagedObjectContext.delete(mastodonAutentication) + _mastodonAutenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: mastodonAutentication.domain, + userID: mastodonAutentication.userID, + appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.appAccessToken), + userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: mastodonAutentication.userAccessToken) + ) + managedObjectContext.delete(mastodonAutentication) isSignOut = true } + .flatMap { result -> AnyPublisher, Never> in + guard let apiService = self.apiService, + let mastodonAuthenticationBox = _mastodonAutenticationBox else { + return Just(result).eraseToAnyPublisher() + } + + return apiService.cancelSubscription( + mastodonAuthenticationBox: mastodonAuthenticationBox + ) + .map { _ in result } + .catch { _ in Just(result).eraseToAnyPublisher() } + .eraseToAnyPublisher() + } .map { result in return result.map { isSignOut } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 098fdff62..526c35883 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -19,69 +19,28 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input - weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) - let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) - + // output /// [Token: UserID] - let notificationSubscriptionDict: [String: NotificationSubscription] = [:] + let notificationSubscriptionDict: [String: NotificationViewModel] = [:] init( - apiService: APIService, authenticationService: AuthenticationService ) { - self.apiService = apiService self.authenticationService = authenticationService - authenticationService.mastodonAuthentications - .handleEvents(receiveOutput: { [weak self] mastodonAuthentications in - guard let self = self else { return } - - // request permission when sign-in - guard !mastodonAuthentications.isEmpty else { return } - self.requestNotificationPermission() - }) - .map { authentications -> [AuthenticationService.MastodonAuthenticationBox] in - return authentications.compactMap { authentication -> AuthenticationService.MastodonAuthenticationBox? in - return AuthenticationService.MastodonAuthenticationBox( - domain: authentication.domain, - userID: authentication.userID, - appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken), - userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken) - ) - } - } - .assign(to: \.value, on: mastodonAuthenticationBoxes) - .store(in: &disposeBag) - deviceToken .receive(on: DispatchQueue.main) .sink { [weak self] deviceToken in - guard let self = self else { return } + guard let _ = self else { return } guard let deviceToken = deviceToken else { return } let token = [UInt8](deviceToken).toHexString() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: deviceToken: %s", ((#file as NSString).lastPathComponent), #line, #function, token) } .store(in: &disposeBag) - - Publishers.CombineLatest3( - isNotificationPermissionGranted, - deviceToken, - mastodonAuthenticationBoxes - ) - .sink { [weak self] isNotificationPermissionGranted, deviceToken, mastodonAuthenticationBoxes in - guard let self = self else { return } - guard isNotificationPermissionGranted else { return } - guard let deviceToken = deviceToken else { return } - self.registerNotificationSubscriptions( - deviceToken: [UInt8](deviceToken).toHexString(), - mastodonAuthenticationBoxes: mastodonAuthenticationBoxes - ) - } - .store(in: &disposeBag) } } @@ -102,35 +61,14 @@ extension NotificationService { // Enable or disable features based on the authorization. } } +} + +extension NotificationService { - private func registerNotificationSubscriptions( - deviceToken: String, - mastodonAuthenticationBoxes: [AuthenticationService.MastodonAuthenticationBox] - ) { - for mastodonAuthenticationBox in mastodonAuthenticationBoxes { - guard let notificationSubscription = dequeueNotificationSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) else { continue } - let token = NotificationSubscription.SubscribeToken( - deviceToken: deviceToken, - authenticationBox: mastodonAuthenticationBox - ) - guard let subscription = subscribe( - notificationSubscription: notificationSubscription, - token: token - ) else { continue } - - subscription - .sink { completion in - // handle error - } receiveValue: { response in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: did create subscription %s with userToken %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, mastodonAuthenticationBox.userAuthorization.accessToken) - // do nothing - } - .store(in: &self.disposeBag) - } - } - - private func dequeueNotificationSubscription(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> NotificationSubscription? { - var _notificationSubscription: NotificationSubscription? + func dequeueNotificationViewModel( + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> NotificationViewModel? { + var _notificationSubscription: NotificationViewModel? workingQueue.sync { let domain = mastodonAuthenticationBox.domain let userID = mastodonAuthenticationBox.userID @@ -139,56 +77,13 @@ extension NotificationService { if let notificationSubscription = notificationSubscriptionDict[key] { _notificationSubscription = notificationSubscription } else { - let notificationSubscription = NotificationSubscription(domain: domain, userID: userID) + let notificationSubscription = NotificationViewModel(domain: domain, userID: userID) _notificationSubscription = notificationSubscription } } return _notificationSubscription } - private func subscribe( - notificationSubscription: NotificationSubscription, - token: NotificationSubscription.SubscribeToken - ) -> AnyPublisher, Error>? { - guard let apiService = self.apiService else { return nil } - - if let oldToken = notificationSubscription.token { - guard oldToken != token else { return nil } - } - notificationSubscription.token = token - - let appSecret = AppSecret.default - let endpoint = appSecret.notificationEndpoint + "/" + token.deviceToken - let p256dh = appSecret.uncompressionNotificationPublicKeyData - let auth = appSecret.notificationAuth - - let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( - subscription: Mastodon.API.Subscriptions.QuerySubscription( - endpoint: endpoint, - keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( - p256dh: p256dh, - auth: auth - ) - ), - data: Mastodon.API.Subscriptions.QueryData( - alerts: Mastodon.API.Subscriptions.QueryData.Alerts( - favourite: true, - follow: true, - reblog: true, - mention: true, - poll: true - ) - ) - ) - - return apiService.createSubscription( - mastodonAuthenticationBox: token.authenticationBox, - query: query, - triggerBy: "anyone", - userID: token.authenticationBox.userID - ) - } - static func createRandomAuthBytes() -> Data { let byteCount = 16 var bytes = Data(count: byteCount) @@ -198,7 +93,7 @@ extension NotificationService { } extension NotificationService { - final class NotificationSubscription { + final class NotificationViewModel { var disposeBag = Set() @@ -206,36 +101,39 @@ extension NotificationService { let domain: String let userID: Mastodon.Entity.Account.ID - var token: SubscribeToken? + // output init(domain: String, userID: Mastodon.Entity.Account.ID) { self.domain = domain self.userID = userID } - - struct SubscribeToken: Equatable { - - let deviceToken: String - let authenticationBox: AuthenticationService.MastodonAuthenticationBox - // TODO: set other parameter - - init( - deviceToken: String, - authenticationBox: AuthenticationService.MastodonAuthenticationBox - ) { - self.deviceToken = deviceToken - self.authenticationBox = authenticationBox - } - - static func == ( - lhs: NotificationService.NotificationSubscription.SubscribeToken, - rhs: NotificationService.NotificationSubscription.SubscribeToken - ) -> Bool { - return lhs.deviceToken == rhs.deviceToken && - lhs.authenticationBox.domain == rhs.authenticationBox.domain && - lhs.authenticationBox.userID == rhs.authenticationBox.userID && - lhs.authenticationBox.userAuthorization.accessToken == rhs.authenticationBox.userAuthorization.accessToken - } - } + } +} + +extension NotificationService.NotificationViewModel { + func createSubscribeQuery( + deviceToken: Data, + queryData: Mastodon.API.Subscriptions.QueryData, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> Mastodon.API.Subscriptions.CreateSubscriptionQuery { + let deviceToken = [UInt8](deviceToken).toHexString() + + let appSecret = AppSecret.default + let endpoint = appSecret.notificationEndpoint + "/" + deviceToken + let p256dh = appSecret.uncompressionNotificationPublicKeyData + let auth = appSecret.notificationAuth + + let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( + subscription: Mastodon.API.Subscriptions.QuerySubscription( + endpoint: endpoint, + keys: Mastodon.API.Subscriptions.QuerySubscription.Keys( + p256dh: p256dh, + auth: auth + ) + ), + data: queryData + ) + + return query } } diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift new file mode 100644 index 000000000..e097b4dc4 --- /dev/null +++ b/Mastodon/Service/SettingService.swift @@ -0,0 +1,162 @@ +// +// SettingService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-25. +// + +import UIKit +import Combine +import CoreDataStack +import MastodonSDK + +final class SettingService { + + var disposeBag = Set() + + private var currentSettingUpdateSubscription: AnyCancellable? + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + weak var notificationService: NotificationService? + + // output + let settingFetchedResultController: SettingFetchedResultController + let currentSetting = CurrentValueSubject(nil) + + init( + apiService: APIService, + authenticationService: AuthenticationService, + notificationService: NotificationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + self.notificationService = notificationService + self.settingFetchedResultController = SettingFetchedResultController( + managedObjectContext: authenticationService.managedObjectContext, + additionalPredicate: nil + ) + + // create setting (if non-exist) for authenticated users + authenticationService.mastodonAuthenticationBoxes + .compactMap { [weak self] mastodonAuthenticationBoxes -> AnyPublisher<[AuthenticationService.MastodonAuthenticationBox], Never>? in + guard let self = self else { return nil } + guard let authenticationService = self.authenticationService else { return nil } + guard let activeMastodonAuthenticationBox = mastodonAuthenticationBoxes.first else { return nil } + + let domain = activeMastodonAuthenticationBox.domain + let userID = activeMastodonAuthenticationBox.userID + return authenticationService.backgroundManagedObjectContext.performChanges { + _ = APIService.CoreData.createOrMergeSetting( + into: authenticationService.backgroundManagedObjectContext, + property: Setting.Property( + domain: domain, + userID: userID, + appearanceRaw: SettingsItem.AppearanceMode.automatic.rawValue + ) + ) + } + .map { _ in mastodonAuthenticationBoxes } + .eraseToAnyPublisher() + } + .sink { _ in + // do nothing + } + .store(in: &disposeBag) + + // bind current setting + Publishers.CombineLatest( + authenticationService.activeMastodonAuthenticationBox, + settingFetchedResultController.settings + ) + .sink { [weak self] activeMastodonAuthenticationBox, settings in + guard let self = self else { return } + guard let activeMastodonAuthenticationBox = activeMastodonAuthenticationBox else { return } + let currentSetting = settings.first(where: { setting in + return setting.domain == activeMastodonAuthenticationBox.domain && + setting.userID == activeMastodonAuthenticationBox.userID + }) + self.currentSetting.value = currentSetting + } + .store(in: &disposeBag) + + // observe current setting + currentSetting + .receive(on: DispatchQueue.main) + .sink { [weak self] setting in + guard let self = self else { return } + guard let setting = setting else { + self.currentSettingUpdateSubscription = nil + return + } + + self.currentSettingUpdateSubscription = ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { change in + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + + // observe apparance mode + switch setting.appearance { + case .automatic: UserDefaults.shared.customUserInterfaceStyle = .unspecified + case .light: UserDefaults.shared.customUserInterfaceStyle = .light + case .dark: UserDefaults.shared.customUserInterfaceStyle = .dark + } + }) + } + .store(in: &disposeBag) + + Publishers.CombineLatest( + notificationService.deviceToken, + currentSetting + ) + .compactMap { [weak self] deviceToken, setting -> AnyPublisher, Error>? in + guard let self = self else { return nil } + guard let apiService = self.apiService else { return nil } + guard let deviceToken = deviceToken else { return nil } + guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil } + guard let setting = setting else { return nil } + guard let subscription = setting.activeSubscription else { return nil } + + guard setting.domain == authenticationBox.domain, + setting.userID == authenticationBox.userID else { return nil } + + let _viewModel = self.notificationService?.dequeueNotificationViewModel( + mastodonAuthenticationBox: authenticationBox + ) + guard let viewModel = _viewModel else { return nil } + let queryData = Mastodon.API.Subscriptions.QueryData( + policy: subscription.policy, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts( + favourite: subscription.alert.favourite, + follow: subscription.alert.follow, + reblog: subscription.alert.reblog, + mention: subscription.alert.mention, + poll: subscription.alert.poll + ) + ) + let query = viewModel.createSubscribeQuery( + deviceToken: deviceToken, + queryData: queryData, + mastodonAuthenticationBox: authenticationBox + ) + + return apiService.createSubscription( + subscriptionObjectID: subscription.objectID, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + } + .switchToLatest() + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &disposeBag) + + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 1ed2c283f..0c40ff127 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -29,6 +29,7 @@ class AppContext: ObservableObject { let statusPrefetchingService: StatusPrefetchingService let statusPublishService = StatusPublishService() let notificationService: NotificationService + let settingService: SettingService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -59,10 +60,16 @@ class AppContext: ObservableObject { statusPrefetchingService = StatusPrefetchingService( apiService: _apiService ) - notificationService = NotificationService( - apiService: _apiService, + let _notificationService = NotificationService( authenticationService: _authenticationService ) + notificationService = _notificationService + + settingService = SettingService( + apiService: _apiService, + authenticationService: _authenticationService, + notificationService: _notificationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/AppSharedName.swift b/Mastodon/Supporting Files/AppSharedName.swift new file mode 100644 index 000000000..3570c68da --- /dev/null +++ b/Mastodon/Supporting Files/AppSharedName.swift @@ -0,0 +1,12 @@ +// +// AppSharedName.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +enum AppSharedName { + static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 0f5e2bd59..1e6c13e41 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -6,10 +6,13 @@ // import UIKit +import Combine import CoreDataStack class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var observations = Set() + var window: UIWindow? var coordinator: SceneCoordinator? @@ -28,8 +31,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { sceneCoordinator.setupOnboardingIfNeeds(animated: false) window.makeKeyAndVisible() - // update `overrideUserInterfaceStyle` with current setting - SettingsViewController.updateOverrideUserInterfaceStyle(window: window) + UserDefaults.shared.observe(\.customUserInterfaceStyle, options: [.initial, .new]) { [weak self] defaults, _ in + guard let self = self else { return } + self.window?.overrideUserInterfaceStyle = defaults.customUserInterfaceStyle + } + .store(in: &observations) } func sceneDidDisconnect(_ scene: UIScene) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift index f78c2c6c6..b66b89aad 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Push.swift @@ -21,7 +21,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -54,7 +54,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -88,7 +88,7 @@ extension Mastodon.API.Subscriptions { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/25 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) /// - Parameters: @@ -114,10 +114,45 @@ extension Mastodon.API.Subscriptions { } .eraseToAnyPublisher() } + + /// Remove current subscription + /// + /// Removes the current Web Push API subscription. + /// + /// - Since: 2.4.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/4/26 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/notifications/push/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Subscription` nested in the response + public static func removeSubscription( + session: URLSession, + domain: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: pushEndpointURL(domain: domain), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.EmptySubscription.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } } extension Mastodon.API.Subscriptions { + public typealias Policy = QueryData.Policy + public struct QuerySubscription: Codable { let endpoint: String let keys: Keys @@ -142,9 +177,14 @@ extension Mastodon.API.Subscriptions { } public struct QueryData: Codable { + let policy: Policy? let alerts: Alerts - public init(alerts: Mastodon.API.Subscriptions.QueryData.Alerts) { + public init( + policy: Policy?, + alerts: Mastodon.API.Subscriptions.QueryData.Alerts + ) { + self.policy = policy self.alerts = alerts } @@ -163,8 +203,39 @@ extension Mastodon.API.Subscriptions { self.poll = poll } } + + public enum Policy: RawRepresentable, Codable { + case all + case followed + case follower + case none + + case _other(String) + + public init?(rawValue: String) { + switch rawValue { + case "all": self = .all + case "followed": self = .followed + case "follower": self = .follower + case "none": self = .none + + default: self = ._other(rawValue) + } + } + + public var rawValue: String { + switch self { + case .all: return "all" + case .followed: return "followed" + case .follower: return "follower" + case .none: return "none" + case ._other(let value): return value + } + } + } } + public struct CreateSubscriptionQuery: Codable, PostQuery { let subscription: QuerySubscription let data: QueryData diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index 1a4496ed3..4fcc8fc9e 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -151,6 +151,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .PUT, query: query, authorization: authorization) } + + static func delete( + url: URL, + query: DeleteQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } private static func buildRequest( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift index 3ae5718e6..e968c32d6 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Subscription.swift @@ -1,5 +1,5 @@ // -// File.swift +// Mastodon+Entity+Subscription.swift // // // Created by ihugo on 2021/4/9. @@ -14,7 +14,7 @@ extension Mastodon.Entity { /// - Since: 2.4.0 /// - Version: 3.3.0 /// # Last Update - /// 2021/4/9 + /// 2021/4/26 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/pushsubscription/) public struct Subscription: Codable { @@ -33,30 +33,19 @@ extension Mastodon.Entity { public struct Alerts: Codable { public let follow: Bool? + public let followRequest: Bool? public let favourite: Bool? public let reblog: Bool? public let mention: Bool? public let poll: Bool? - public var followNumber: NSNumber? { - guard let value = follow else { return nil } - return NSNumber(booleanLiteral: value) - } - public var favouriteNumber: NSNumber? { - guard let value = favourite else { return nil } - return NSNumber(booleanLiteral: value) - } - public var reblogNumber: NSNumber? { - guard let value = reblog else { return nil } - return NSNumber(booleanLiteral: value) - } - public var mentionNumber: NSNumber? { - guard let value = mention else { return nil } - return NSNumber(booleanLiteral: value) - } - public var pollNumber: NSNumber? { - guard let value = poll else { return nil } - return NSNumber(booleanLiteral: value) + enum CodingKeys: String, CodingKey { + case follow + case followRequest = "follow_request" + case favourite + case reblog + case mention + case poll } } @@ -74,4 +63,8 @@ extension Mastodon.Entity { serverKey = try container.decode(String.self, forKey: .serverKey) } } + + public struct EmptySubscription: Codable { + + } } diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 39f6e3ec4..b729129bd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -58,3 +58,5 @@ protocol PatchQuery: RequestQuery { } // PUT protocol PutQuery: RequestQuery { } +// DELETE +protocol DeleteQuery: RequestQuery { } From 7eb79414a45607e0fe16fecc6167c83071f03895 Mon Sep 17 00:00:00 2001 From: ihugo Date: Mon, 26 Apr 2021 17:41:24 +0800 Subject: [PATCH 323/400] fix: memery leak --- .../Diffiable/Section/ReportSection.swift | 3 ++- .../Diffiable/Section/StatusSection.swift | 26 +++++++++++++------ Mastodon/Scene/Report/ReportViewModel.swift | 8 +++--- .../Report/ReportedStatusTableviewCell.swift | 2 +- .../View/Content/TimelineHeaderView.swift | 2 ++ 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index e14a959f7..6faaae6c2 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -36,7 +36,8 @@ extension ReportSection { cell.dependency = dependency let activeMastodonAuthenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value let requestUserID = activeMastodonAuthenticationBox?.userID ?? "" - managedObjectContext.performAndWait { + managedObjectContext.performAndWait { [weak dependency] in + guard let dependency = dependency else { return } let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index ea3332f14..b897de47f 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -149,7 +149,8 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } guard case .update(let object) = change.changeType, let newStatus = object as? Status else { return } StatusSection.configureHeader(cell: cell, status: newStatus) @@ -221,7 +222,8 @@ extension StatusSection { } else { meta.blurhashImagePublisher() .receive(on: DispatchQueue.main) - .sink { image in + .sink { [weak cell] image in + guard let cell = cell else { return } blurhashOverlayImageView.image = image image?.pngData().flatMap { blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) @@ -246,7 +248,8 @@ extension StatusSection { statusItemAttribute.isRevealing ) .receive(on: DispatchQueue.main) - .sink { isImageLoaded, isMediaRevealing in + .sink { [weak cell] isImageLoaded, isMediaRevealing in + guard let cell = cell else { return } guard isImageLoaded else { blurhashOverlayImageView.alpha = 1 blurhashOverlayImageView.isHidden = false @@ -355,7 +358,8 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { [weak dependency] change in + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } @@ -382,7 +386,8 @@ extension StatusSection { ManagedObjectObserver.observe(object: poll) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } guard case .update(let object) = change.changeType, let newPoll = object as? Poll else { return } StatusSection.configurePoll( @@ -413,7 +418,8 @@ extension StatusSection { let createdAt = (status.reblog ?? status).createdAt cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow timestampUpdatePublisher - .sink { _ in + .sink { [weak cell] _ in + guard let cell = cell else { return } cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow } .store(in: &cell.disposeBag) @@ -423,7 +429,9 @@ extension StatusSection { .receive(on: DispatchQueue.main) .sink { _ in // do nothing - } receiveValue: { change in + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } + guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } StatusSection.configureActionToolBar( @@ -759,7 +767,9 @@ extension StatusSection { } var children: [UIMenuElement] = [] let name = author.displayNameWithFallback - let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { _ in + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { + [weak dependency] _ in + guard let dependency = dependency else { return } let viewModel = ReportViewModel( context: dependency.context, domain: authenticationBox.domain, diff --git a/Mastodon/Scene/Report/ReportViewModel.swift b/Mastodon/Scene/Report/ReportViewModel.swift index b787cf6c7..8631963c5 100644 --- a/Mastodon/Scene/Report/ReportViewModel.swift +++ b/Mastodon/Scene/Report/ReportViewModel.swift @@ -133,13 +133,11 @@ class ReportViewModel: NSObject { } .store(in: &disposeBag) - input.comment.assign( - to: \.comment, - on: self - ) - .store(in: &disposeBag) input.comment.sink { [weak self] (comment) in guard let self = self else { return } + + self.comment = comment + let sendEnable = (comment?.length ?? 0) > 0 self.sendEnableSubject.send(sendEnable) } diff --git a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift index 3cbfface5..b1d0af6b0 100644 --- a/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift +++ b/Mastodon/Scene/Report/ReportedStatusTableviewCell.swift @@ -17,7 +17,7 @@ final class ReportedStatusTableViewCell: UITableViewCell, StatusCell { static let bottomPaddingHeight: CGFloat = 10 - var dependency: ReportViewController? + weak var dependency: ReportViewController? var disposeBag = Set() var pollCountdownSubscription: AnyCancellable? var observations = Set() diff --git a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift index b5e4c5bde..f095f6f44 100644 --- a/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift +++ b/Mastodon/Scene/Share/View/Content/TimelineHeaderView.swift @@ -5,6 +5,8 @@ // Created by MainasuK Cirno on 2021-4-6. // +import UIKit + final class TimelineHeaderView: UIView { let iconImageView: UIImageView = { From 59760696b57c06dfb268a7ff33f85c17a6630329 Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 26 Apr 2021 18:19:20 +0800 Subject: [PATCH 324/400] feat: set badge auto increment and clear when app resume --- Mastodon.xcodeproj/project.pbxproj | 10 ++++++++++ .../Preference/NotificationPreference.swift | 20 +++++++++++++++++++ Mastodon/Supporting Files/SceneDelegate.swift | 4 ++++ NotificationService/NotificationService.swift | 3 +++ 4 files changed, 37 insertions(+) create mode 100644 Mastodon/Preference/NotificationPreference.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 8bca2db49..512c89938 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -392,6 +392,10 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; + DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; @@ -862,6 +866,7 @@ DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPreference.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; DBF8AE13263293E400C9C23C /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1605,6 +1610,7 @@ children = ( DB5086BD25CC0D9900C2C187 /* SplashPreference.swift */, DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */, + DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */, ); path = Preference; sourceTree = ""; @@ -2795,6 +2801,7 @@ 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, + DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */, 2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */, DB98339C25C96DE600AD9700 /* APIService+Account.swift in Sources */, 2D42FF6B25C817D2004A627A /* MastodonStatusContent.swift in Sources */, @@ -2918,7 +2925,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, DB6D9F232635195E008423CD /* String.swift in Sources */, + DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */, + DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift new file mode 100644 index 000000000..b634d77f3 --- /dev/null +++ b/Mastodon/Preference/NotificationPreference.swift @@ -0,0 +1,20 @@ +// +// NotificationPreference.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import UIKit + +extension UserDefaults { + + @objc dynamic var notificationBadgeCount: Int { + get { + register(defaults: [#function: 0]) + return integer(forKey: #function) + } + set { UserDefaults.shared[#function] = newValue } + } + +} diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 1e6c13e41..8dd978a8c 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -48,6 +48,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + + // reset notification badge + UserDefaults.shared.notificationBadgeCount = 0 + UIApplication.shared.applicationIconBadgeNumber = 0 } func sceneWillResignActive(_ scene: UIScene) { diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 911866f17..e20fc23b2 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -59,6 +59,9 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + UserDefaults.shared.notificationBadgeCount += 1 + bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) + if let urlString = notification.icon, let url = URL(string: urlString) { let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("notification-attachments") try? FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true, attributes: nil) From dcb1bb7053b76f579e5db5152d870f455b2d4218 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 26 Apr 2021 20:36:59 +0800 Subject: [PATCH 325/400] fix: change delete to Delete, set deleteAction attributes: [.destructive] --- Localization/app.json | 2 +- Mastodon/Generated/Strings.swift | 2 +- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- Mastodon/Resources/en.lproj/infoPlist.strings | 2 ++ .../Register/MastodonRegisterViewController+Avatar.swift | 2 +- .../ServerRules/MastodonServerRulesViewController.swift | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 Mastodon/Resources/en.lproj/infoPlist.strings diff --git a/Localization/app.json b/Localization/app.json index eda508e48..1f5ccade3 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -146,7 +146,7 @@ "title": "Tell us about you.", "input": { "avatar": { - "delete": "delete" + "delete": "Delete" }, "username": { "placeholder": "username", diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 776d69d50..6d7af089d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -502,7 +502,7 @@ internal enum L10n { } internal enum Input { internal enum Avatar { - /// delete + /// Delete internal static let delete = L10n.tr("Localizable", "Scene.Register.Input.Avatar.Delete") } internal enum DisplayName { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 5d27c43b1..ce7a3a2fe 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -162,7 +162,7 @@ tap the link to confirm your account."; "Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; "Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; "Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)"; -"Scene.Register.Input.Avatar.Delete" = "delete"; +"Scene.Register.Input.Avatar.Delete" = "Delete"; "Scene.Register.Input.DisplayName.Placeholder" = "display name"; "Scene.Register.Input.Email.Placeholder" = "email"; "Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; diff --git a/Mastodon/Resources/en.lproj/infoPlist.strings b/Mastodon/Resources/en.lproj/infoPlist.strings new file mode 100644 index 000000000..48566ae36 --- /dev/null +++ b/Mastodon/Resources/en.lproj/infoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Used to take photo for post status"; +"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift index b6ba6a8af..b1fa1b432 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController+Avatar.swift @@ -32,7 +32,7 @@ extension MastodonRegisterViewController { } children.append(browseAction) if self.viewModel.avatarImage.value != nil { - let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + let deleteAction = UIAction(title: L10n.Scene.Register.Input.Avatar.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { [weak self] _ in guard let self = self else { return } self.viewModel.avatarImage.value = nil } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 610d87f34..fb86e81e1 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -58,6 +58,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency textView.textColor = .label textView.isSelectable = true textView.isEditable = false + textView.isScrollEnabled = false textView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color return textView }() @@ -120,7 +121,6 @@ extension MastodonServerRulesViewController { bottomPromptTextView.frameLayoutGuide.topAnchor.constraint(equalTo: bottomContainerView.topAnchor, constant: 20), bottomPromptTextView.frameLayoutGuide.leadingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.leadingAnchor), bottomPromptTextView.frameLayoutGuide.trailingAnchor.constraint(equalTo: bottomContainerView.readableContentGuide.trailingAnchor), - bottomPromptTextView.frameLayoutGuide.heightAnchor.constraint(equalToConstant: 50), confirmButton.topAnchor.constraint(equalTo: bottomPromptTextView.frameLayoutGuide.bottomAnchor, constant: 20), ]) From 42f63808df17c75be307fed7f62b0e9dd8882e94 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 14:27:27 +0800 Subject: [PATCH 326/400] feature: add follow request notification --- Localization/app.json | 3 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Section/NotificationSection.swift | 1 + .../Mastodon+Entity+Notification+Type.swift | 6 +++ Mastodon/Generated/Strings.swift | 2 + .../Resources/en.lproj/Localizable.strings | 1 + ...otificationViewModel+LoadLatestState.swift | 2 +- .../NotificationTableViewCell.swift | 52 ++++++++++++++++--- 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 1f5ccade3..6526ca4b8 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -347,7 +347,8 @@ "favourite": "favorited your post", "reblog": "rebloged your post", "poll": "Your poll has ended", - "mention": "mentioned you" + "mention": "mentioned you", + "follow_request": "request to follow you" }, }, "thread": { diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..4c5c26898 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,7 +51,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 9c59350b4..57c755818 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -108,6 +108,7 @@ extension NotificationSection { if let actionImage = UIImage(systemName: actionImageName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold))?.withRenderingMode(.alwaysTemplate) { cell.actionImageView.image = actionImage } + cell.buttonStackView.isHidden = (type != .followRequest) return cell } case .bottomLoader: diff --git a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift index 77a7b412e..2037f54a2 100644 --- a/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift +++ b/Mastodon/Extension/MastodonSDK/Mastodon+Entity+Notification+Type.swift @@ -24,6 +24,8 @@ extension Mastodon.Entity.Notification.NotificationType { color = Asset.Colors.Notification.mention.color case .poll: color = Asset.Colors.brandBlue.color + case .followRequest: + color = Asset.Colors.brandBlue.color default: color = .clear } @@ -45,6 +47,8 @@ extension Mastodon.Entity.Notification.NotificationType { actionText = L10n.Scene.Notification.Action.mention case .poll: actionText = L10n.Scene.Notification.Action.poll + case .followRequest: + actionText = L10n.Scene.Notification.Action.followRequest default: actionText = "" } @@ -66,6 +70,8 @@ extension Mastodon.Entity.Notification.NotificationType { actionImageName = "at" case .poll: actionImageName = "list.bullet" + case .followRequest: + actionImageName = "person.crop.circle" default: actionImageName = "" } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 6d7af089d..0b657949f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -371,6 +371,8 @@ internal enum L10n { internal static let favourite = L10n.tr("Localizable", "Scene.Notification.Action.Favourite") /// followed you internal static let follow = L10n.tr("Localizable", "Scene.Notification.Action.Follow") + /// request to follow you + internal static let followRequest = L10n.tr("Localizable", "Scene.Notification.Action.FollowRequest") /// mentioned you internal static let mention = L10n.tr("Localizable", "Scene.Notification.Action.Mention") /// Your poll has ended diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index ce7a3a2fe..e87982016 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -125,6 +125,7 @@ tap the link to confirm your account."; "Scene.HomeTimeline.Title" = "Home"; "Scene.Notification.Action.Favourite" = "favorited your post"; "Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.FollowRequest" = "request to follow you"; "Scene.Notification.Action.Mention" = "mentioned you"; "Scene.Notification.Action.Poll" = "Your poll has ended"; "Scene.Notification.Action.Reblog" = "rebloged your post"; diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d622..e2b04804d 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -53,7 +53,7 @@ extension NotificationViewModel.LoadLatestState { sinceID: nil, minID: nil, limit: nil, - excludeTypes: [.followRequest], + excludeTypes: [], accountID: nil ) viewModel.context.apiService.allNotifications( diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 619bffa17..809dc9b2b 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -21,6 +21,10 @@ protocol NotificationTableViewCellDelegate: AnyObject { func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) +// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) +// +// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, denyButtonDidPressed button: UIButton) + } final class NotificationTableViewCell: UITableViewCell { @@ -76,6 +80,24 @@ final class NotificationTableViewCell: UITableViewCell { return label }() + let acceptButton: UIButton = { + let button = UIButton(type: .custom) + let actionImage = UIImage(systemName: "checkmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) + button.setImage(actionImage, for: .normal) + button.tintColor = Asset.Colors.Label.secondary.color + return button + }() + + let rejectButton: UIButton = { + let button = UIButton(type: .custom) + let actionImage = UIImage(systemName: "xmark.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .semibold))?.withRenderingMode(.alwaysTemplate) + button.setImage(actionImage, for: .normal) + button.tintColor = Asset.Colors.Label.secondary.color + return button + }() + + let buttonStackView = UIStackView() + override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() @@ -97,9 +119,8 @@ extension NotificationTableViewCell { func configure() { let containerStackView = UIStackView() - containerStackView.axis = .horizontal - containerStackView.alignment = .center - containerStackView.spacing = 4 + containerStackView.axis = .vertical + containerStackView.alignment = .fill containerStackView.layoutMargins = UIEdgeInsets(top: 14, left: 0, bottom: 12, right: 0) containerStackView.isLayoutMarginsRelativeArrangement = true containerStackView.translatesAutoresizingMaskIntoConstraints = false @@ -110,8 +131,13 @@ extension NotificationTableViewCell { contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerStackView.trailingAnchor), contentView.bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor), ]) + + let horizontalStackView = UIStackView() + horizontalStackView.translatesAutoresizingMaskIntoConstraints = false + horizontalStackView.axis = .horizontal + horizontalStackView.spacing = 6 - containerStackView.addArrangedSubview(avatarContainer) + horizontalStackView.addArrangedSubview(avatarContainer) avatarContainer.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ avatarContainer.heightAnchor.constraint(equalToConstant: 47).priority(.required - 1), @@ -144,13 +170,23 @@ extension NotificationTableViewCell { ]) nameLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(nameLabel) + horizontalStackView.addArrangedSubview(nameLabel) actionLabel.translatesAutoresizingMaskIntoConstraints = false - containerStackView.addArrangedSubview(actionLabel) - nameLabel.setContentCompressionResistancePriority(.required - 1, for: .vertical) - nameLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + horizontalStackView.addArrangedSubview(actionLabel) + nameLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) + nameLabel.setContentHuggingPriority(.required - 1, for: .horizontal) actionLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + containerStackView.addArrangedSubview(horizontalStackView) + + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.axis = .horizontal + buttonStackView.distribution = .fillEqually + acceptButton.translatesAutoresizingMaskIntoConstraints = false + denyButton.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.addArrangedSubview(acceptButton) + buttonStackView.addArrangedSubview(rejectButton) + containerStackView.addArrangedSubview(buttonStackView) } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { From 124d4eef0a863ad37c9462e15a90b9090b0a6391 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 14:43:38 +0800 Subject: [PATCH 327/400] feature: add followRequest API --- .../NotificationTableViewCell.swift | 2 +- .../Mastodon+API+Account+FollowRequest.swift | 89 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 809dc9b2b..dc5c4c19c 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -183,7 +183,7 @@ extension NotificationTableViewCell { buttonStackView.axis = .horizontal buttonStackView.distribution = .fillEqually acceptButton.translatesAutoresizingMaskIntoConstraints = false - denyButton.translatesAutoresizingMaskIntoConstraints = false + rejectButton.translatesAutoresizingMaskIntoConstraints = false buttonStackView.addArrangedSubview(acceptButton) buttonStackView.addArrangedSubview(rejectButton) containerStackView.addArrangedSubview(buttonStackView) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift new file mode 100644 index 000000000..447ce714f --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -0,0 +1,89 @@ +// +// Mastodon+API+Account+FollowRequest.swift +// +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation +import Combine + +// MARK: - Account credentials +extension Mastodon.API.Account { + + static func acceptFollowRequestEndpointURL(domain: String, userID: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("authorize") + } + + static func rejectFollowRequestEndpointURL(domain: String, userID: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") + .appendingPathComponent(userID) + .appendingPathComponent("reject") + } + + /// Accept Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.0.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func acceptFollowRequest( + session: URLSession, + domain: String, + userID: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: acceptFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Reject Follow + /// + /// + /// - Since: 0.0.0 + /// - Version: 3.0.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - userID: ID of the account in the database + /// - authorization: App token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func rejectFollowRequest( + session: URLSession, + domain: String, + userID: String, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let request = Mastodon.API.post( + url: rejectFollowRequestEndpointURL(domain: domain, userID: userID), + query: nil, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} From 381bf379267955976da26599a05a2c575842741b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 15:33:47 +0800 Subject: [PATCH 328/400] fix: delete old notifications in CoreData --- Mastodon.xcodeproj/project.pbxproj | 4 + .../UserProvider/UserProviderFacade.swift | 3 +- .../SuggestionAccountViewModel.swift | 3 +- .../APIService/APIService+Follow.swift | 23 ++-- .../APIService/APIService+FollowRequest.swift | 105 ++++++++++++++++++ .../APIService/APIService+Notification.swift | 8 ++ .../Mastodon+API+Account+FollowRequest.swift | 12 +- 7 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+FollowRequest.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index d9af89e1b..ad9f94dad 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -110,6 +110,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */; }; 2D8434FB25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */; }; 2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; }; + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */; }; 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0125C7E4F2004F19B8 /* Mention.swift */; }; 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0725C7E9A8004F19B8 /* Tag.swift */; }; 2D927F0E25C7E9C9004F19B8 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D927F0D25C7E9C9004F19B8 /* History.swift */; }; @@ -528,6 +529,7 @@ 2D8434F425FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleViewModel.swift; sourceTree = ""; }; 2D8434FA25FF46B300EECE90 /* HomeTimelineNavigationBarTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineNavigationBarTitleView.swift; sourceTree = ""; }; 2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = ""; }; + 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+FollowRequest.swift"; sourceTree = ""; }; 2D927F0125C7E4F2004F19B8 /* Mention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mention.swift; sourceTree = ""; }; 2D927F0725C7E9A8004F19B8 /* Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tag.swift; sourceTree = ""; }; 2D927F0D25C7E9C9004F19B8 /* History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = ""; }; @@ -1477,6 +1479,7 @@ 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */, DBCC3B9426157E6E0045B23D /* APIService+Relationship.swift */, DBAE3F932616E28B004B8251 /* APIService+Follow.swift */, + 2D8FCA072637EABB00137F46 /* APIService+FollowRequest.swift */, DBAE3F8D2616E0B1004B8251 /* APIService+Block.swift */, DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */, 5B90C48426259BF10002E742 /* APIService+Subscriptions.swift */, @@ -2406,6 +2409,7 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, + 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */, 2DE0FAC82615F5F000CDF649 /* SearchRecommendAccountsCollectionViewCell.swift in Sources */, 2DFAD5272616F9D300F9EE7C /* SearchViewController+Searching.swift in Sources */, diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index b64bfe79d..b5f4dd32f 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -44,8 +44,7 @@ extension UserProviderFacade { return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: true + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox ) } .switchToLatest() diff --git a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift index d5ef6f6c7..7a508fc75 100644 --- a/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift +++ b/Mastodon/Scene/SuggestionAccount/SuggestionAccountViewModel.swift @@ -188,8 +188,7 @@ final class SuggestionAccountViewModel: NSObject { let mastodonUser = context.managedObjectContext.object(with: objectID) as! MastodonUser return context.apiService.toggleFollow( for: mastodonUser, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - needFeedback: false + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox ) } diff --git a/Mastodon/Service/APIService/APIService+Follow.swift b/Mastodon/Service/APIService/APIService+Follow.swift index 6db612942..53634ab4b 100644 --- a/Mastodon/Service/APIService/APIService+Follow.swift +++ b/Mastodon/Service/APIService/APIService+Follow.swift @@ -24,15 +24,12 @@ extension APIService { /// - Returns: publisher for `Relationship` func toggleFollow( for mastodonUser: MastodonUser, - activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox, - needFeedback: Bool + activeMastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox ) -> AnyPublisher, Error> { - var impactFeedbackGenerator: UIImpactFeedbackGenerator? - var notificationFeedbackGenerator: UINotificationFeedbackGenerator? - if needFeedback { - impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) - notificationFeedbackGenerator = UINotificationFeedbackGenerator() - } + + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + return followUpdateLocal( mastodonUserObjectID: mastodonUser.objectID, @@ -40,9 +37,9 @@ extension APIService { ) .receive(on: DispatchQueue.main) .handleEvents { _ in - impactFeedbackGenerator?.prepare() + impactFeedbackGenerator.prepare() } receiveOutput: { _ in - impactFeedbackGenerator?.impactOccurred() + impactFeedbackGenerator.impactOccurred() } receiveCompletion: { completion in switch completion { case .failure(let error): @@ -79,13 +76,13 @@ extension APIService { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Friendship] rollback finish", ((#file as NSString).lastPathComponent), #line, #function) } receiveValue: { _ in // do nothing - notificationFeedbackGenerator?.prepare() - notificationFeedbackGenerator?.notificationOccurred(.error) + notificationFeedbackGenerator.prepare() + notificationFeedbackGenerator.notificationOccurred(.error) } .store(in: &self.disposeBag) case .finished: - notificationFeedbackGenerator?.notificationOccurred(.success) + notificationFeedbackGenerator.notificationOccurred(.success) os_log("%{public}s[%{public}ld], %{public}s: [Friendship] remote friendship update success", ((#file as NSString).lastPathComponent), #line, #function) } }) diff --git a/Mastodon/Service/APIService/APIService+FollowRequest.swift b/Mastodon/Service/APIService/APIService+FollowRequest.swift new file mode 100644 index 000000000..c40fcad52 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+FollowRequest.swift @@ -0,0 +1,105 @@ +// +// APIService+FollowRequest.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/27. +// + +import Foundation + +import UIKit +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + func acceptFollowRequest( + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Account.acceptFollowRequest( + session: session, + domain: domain, + userID: mastodonUserID, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func rejectFollowRequest( + mastodonUserID: MastodonUser.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + let requestMastodonUserID = mastodonAuthenticationBox.userID + + return Mastodon.API.Account.rejectFollowRequest( + session: session, + domain: domain, + userID: mastodonUserID, + authorization: authorization) + .flatMap { response -> AnyPublisher, Error> in + let managedObjectContext = self.backgroundManagedObjectContext + return managedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: requestMastodonUserID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = managedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + + let lookUpMastodonUserRequest = MastodonUser.sortedFetchRequest + lookUpMastodonUserRequest.predicate = MastodonUser.predicate(domain: domain, id: mastodonUserID) + lookUpMastodonUserRequest.fetchLimit = 1 + let lookUpMastodonuser = managedObjectContext.safeFetch(lookUpMastodonUserRequest).first + + if let lookUpMastodonuser = lookUpMastodonuser { + let entity = response.value + APIService.CoreData.update(user: lookUpMastodonuser, entity: entity, requestMastodonUser: requestMastodonUser, domain: domain, networkDate: response.networkDate) + } + } + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186c..a27aae2ae 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -28,6 +28,14 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in let log = OSLog.api + if query.maxID == nil { + let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest + requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) + let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) + oldNotifications.forEach { notification in + self.backgroundManagedObjectContext.delete(notification) + } + } return self.backgroundManagedObjectContext.performChanges { response.value.forEach { notification in let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index 447ce714f..f08e888b5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -35,13 +35,13 @@ extension Mastodon.API.Account { /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func acceptFollowRequest( session: URLSession, domain: String, userID: String, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let request = Mastodon.API.post( url: acceptFollowRequestEndpointURL(domain: domain, userID: userID), query: nil, @@ -49,7 +49,7 @@ extension Mastodon.API.Account { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() @@ -67,13 +67,13 @@ extension Mastodon.API.Account { /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database /// - authorization: App token - /// - Returns: `AnyPublisher` contains `Account` nested in the response + /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func rejectFollowRequest( session: URLSession, domain: String, userID: String, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let request = Mastodon.API.post( url: rejectFollowRequestEndpointURL(domain: domain, userID: userID), query: nil, @@ -81,7 +81,7 @@ extension Mastodon.API.Account { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Relationship.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() From aca358db26bfe260980f41616eb6dadf8358c816 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 16:26:59 +0800 Subject: [PATCH 329/400] feat: persist notification keys into Keychian --- AppShared/AppName.swift | 12 + AppShared/AppSecret.swift | 103 ++++++ AppShared/AppShared.h | 18 + AppShared/Info.plist | 22 ++ AppShared/UserDefaults.swift | 12 + CoreDataStack/CoreDataStack.swift | 3 +- Mastodon.xcodeproj/project.pbxproj | 327 ++++++++++++++++-- .../xcschemes/xcschememanagement.plist | 9 +- .../xcshareddata/swiftpm/Package.resolved | 9 + Mastodon/Extension/String.swift | 22 ++ Mastodon/Extension/UserDefaults.swift | 5 +- .../Preference/AppearancePreference.swift | 2 +- .../Preference/NotificationPreference.swift | 2 +- .../APIService/APIService+Subscriptions.swift | 5 +- .../APIService+CoreData+Setting.swift | 57 +-- .../APIService+CoreData+Subscriptions.swift | 1 + Mastodon/Service/NotificationService.swift | 21 +- Mastodon/Service/SettingService.swift | 37 +- Mastodon/Supporting Files/AppDelegate.swift | 26 +- Mastodon/Supporting Files/AppSecret.swift | 51 --- Mastodon/Supporting Files/AppSharedName.swift | 12 - NotificationService/Extension/String.swift | 31 -- .../MastodonNotification.swift | 35 ++ .../NotificationService+Decrypt.swift | 37 +- .../NotificationService.entitlements | 10 + NotificationService/NotificationService.swift | 9 +- Podfile | 10 +- Podfile.lock | 2 +- README.md | 1 + 29 files changed, 673 insertions(+), 218 deletions(-) create mode 100644 AppShared/AppName.swift create mode 100644 AppShared/AppSecret.swift create mode 100644 AppShared/AppShared.h create mode 100644 AppShared/Info.plist create mode 100644 AppShared/UserDefaults.swift delete mode 100644 Mastodon/Supporting Files/AppSecret.swift delete mode 100644 Mastodon/Supporting Files/AppSharedName.swift delete mode 100644 NotificationService/Extension/String.swift create mode 100644 NotificationService/MastodonNotification.swift create mode 100644 NotificationService/NotificationService.entitlements diff --git a/AppShared/AppName.swift b/AppShared/AppName.swift new file mode 100644 index 000000000..9dbca78d8 --- /dev/null +++ b/AppShared/AppName.swift @@ -0,0 +1,12 @@ +// +// AppName.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import Foundation + +public enum AppName { + public static let groupID = "group.org.joinmastodon.mastodon-temp" +} diff --git a/AppShared/AppSecret.swift b/AppShared/AppSecret.swift new file mode 100644 index 000000000..e2305ef1a --- /dev/null +++ b/AppShared/AppSecret.swift @@ -0,0 +1,103 @@ +// +// AppSecret.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + + +import Foundation +import CryptoKit +import KeychainAccess +import Keys + +public final class AppSecret { + + public static let keychain = Keychain(service: "org.joinmastodon.Mastodon.keychain", accessGroup: AppName.groupID) + + static let notificationPrivateKeyName = "notification-private-key-base64" + static let notificationAuthName = "notification-auth-base64" + + public let notificationEndpoint: String + + public var notificationPrivateKey: P256.KeyAgreement.PrivateKey { + AppSecret.createOrFetchNotificationPrivateKey() + } + public var notificationPublicKey: P256.KeyAgreement.PublicKey { + notificationPrivateKey.publicKey + } + public var notificationAuth: Data { + AppSecret.createOrFetchNotificationAuth() + } + + public static let `default`: AppSecret = { + return AppSecret() + }() + + init() { + let keys = MastodonKeys() + + #if DEBUG + self.notificationEndpoint = keys.notification_endpoint_debug + #else + self.notificationEndpoint = keys.notification_endpoint + #endif + } + + public func register() { + _ = AppSecret.createOrFetchNotificationPrivateKey() + _ = AppSecret.createOrFetchNotificationAuth() + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + if let encoded = AppSecret.keychain[AppSecret.notificationPrivateKeyName], + let data = Data(base64Encoded: encoded) { + do { + let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: data) + return privateKey + } catch { + assertionFailure() + return AppSecret.resetNotificationPrivateKey() + } + } else { + return AppSecret.resetNotificationPrivateKey() + } + } + + private static func resetNotificationPrivateKey() -> P256.KeyAgreement.PrivateKey { + let privateKey = P256.KeyAgreement.PrivateKey() + keychain[AppSecret.notificationPrivateKeyName] = privateKey.rawRepresentation.base64EncodedString() + return privateKey + } + +} + +extension AppSecret { + + private static func createOrFetchNotificationAuth() -> Data { + if let encoded = keychain[AppSecret.notificationAuthName], + let data = Data(base64Encoded: encoded) { + return data + } else { + return AppSecret.resetNotificationAuth() + } + } + + private static func resetNotificationAuth() -> Data { + let auth = AppSecret.createRandomAuthBytes() + keychain[AppSecret.notificationAuthName] = auth.base64EncodedString() + return auth + } + + private static func createRandomAuthBytes() -> Data { + let byteCount = 16 + var bytes = Data(count: byteCount) + _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } + return bytes + } + +} diff --git a/AppShared/AppShared.h b/AppShared/AppShared.h new file mode 100644 index 000000000..3258d4fcb --- /dev/null +++ b/AppShared/AppShared.h @@ -0,0 +1,18 @@ +// +// AppShared.h +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +#import + +//! Project version number for AppShared. +FOUNDATION_EXPORT double AppSharedVersionNumber; + +//! Project version string for AppShared. +FOUNDATION_EXPORT const unsigned char AppSharedVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/AppShared/Info.plist b/AppShared/Info.plist new file mode 100644 index 000000000..9bcb24442 --- /dev/null +++ b/AppShared/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/AppShared/UserDefaults.swift b/AppShared/UserDefaults.swift new file mode 100644 index 000000000..9cecdcf60 --- /dev/null +++ b/AppShared/UserDefaults.swift @@ -0,0 +1,12 @@ +// +// UserDefaults.swift +// AppShared +// +// Created by MainasuK Cirno on 2021-4-27. +// + +import UIKit + +extension UserDefaults { + public static let shared = UserDefaults(suiteName: AppName.groupID)! +} diff --git a/CoreDataStack/CoreDataStack.swift b/CoreDataStack/CoreDataStack.swift index 766bcf4de..64bf9c857 100644 --- a/CoreDataStack/CoreDataStack.swift +++ b/CoreDataStack/CoreDataStack.swift @@ -8,6 +8,7 @@ import os import Foundation import CoreData +import AppShared public final class CoreDataStack { @@ -18,7 +19,7 @@ public final class CoreDataStack { } public convenience init(databaseName: String = "shared") { - let storeURL = URL.storeURL(for: AppSharedName.groupID, databaseName: databaseName) + let storeURL = URL.storeURL(for: AppName.groupID, databaseName: databaseName) let storeDescription = NSPersistentStoreDescription(url: storeURL) self.init(persistentStoreDescriptions: [storeDescription]) } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 512c89938..37b92876b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -167,6 +167,7 @@ 5E44BF88AD33646E64727BCF /* Pods_MastodonTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */; }; 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */; }; 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */; }; + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */; }; DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */ = {isa = PBXBuildFile; productRef = DB00CA962632DDB600A54956 /* CommonOSLog */; }; DB0140A125C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */; }; DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */; }; @@ -180,7 +181,6 @@ DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; - DB1E05E1263180F500201847 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB1E346825F518E20079D7DF /* CategoryPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */; }; DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E347725F519300079D7DF /* PickServerItem.swift */; }; DB1FD43625F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */; }; @@ -248,6 +248,21 @@ DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804662636DC9000430867 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68045A2636DC6A00430867 /* MastodonNotification.swift */; }; + DB6804832637CD4C00430867 /* AppShared.h in Headers */ = {isa = PBXBuildFile; fileRef = DB6804812637CD4C00430867 /* AppShared.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -256,13 +271,9 @@ DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */; }; DB6C8C0F25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */; }; DB6D1B24263684C600ACB481 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; - DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; - DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; DB6D1B3D2636857500ACB481 /* AppearancePreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */; }; DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */; }; - DB6D9F232635195E008423CD /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F222635195E008423CD /* String.swift */; }; DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */; }; - DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1E05E0263180F500201847 /* AppSecret.swift */; }; DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */ = {isa = PBXBuildFile; productRef = DB6D9F41263527CE008423CD /* AlamofireImage */; }; DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4826353FD6008423CD /* Subscription.swift */; }; DB6D9F502635761F008423CD /* SubscriptionAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */; }; @@ -392,7 +403,6 @@ DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; - DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B2A2636852000ACB481 /* AppSharedName.swift */; }; DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; @@ -419,6 +429,34 @@ remoteGlobalIDString = DB427DD125BAA00100D1B89D; remoteInfo = Mastodon; }; + DB6804842637CD4C00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804A72637CDCC00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6804C92637CE3000430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; + DB6805282637D7DD00430867 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB68047E2637CD4C00430867; + remoteInfo = AppShared; + }; DB89B9F825C10FD0008580ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -450,12 +488,35 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + DB6804A92637CDCC00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + DB68052A2637D7DD00430867 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; DB89BA0825C10FD0008580ED /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( + DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, ); @@ -608,6 +669,7 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 5B90C459262599800002E742 /* SettingsToggleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsToggleTableViewCell.swift; sourceTree = ""; }; 5B90C45A262599800002E742 /* SettingsAppearanceTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAppearanceTableViewCell.swift; sourceTree = ""; }; @@ -636,9 +698,11 @@ 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.debug.xcconfig"; sourceTree = ""; }; 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.release.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.release.xcconfig"; sourceTree = ""; }; A4ABE34829701A4496C5BB64 /* Pods_Mastodon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.release.xcconfig"; sourceTree = ""; }; B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-NotificationService/Pods-Mastodon-NotificationService.release.xcconfig"; sourceTree = ""; }; BB482D32A7B9825BF5327C4F /* Pods-Mastodon-MastodonUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.release.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.release.xcconfig"; sourceTree = ""; }; CD92E0F10BDE4FE7C4B999F2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-AppShared.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-AppShared/Pods-Mastodon-AppShared.debug.xcconfig"; sourceTree = ""; }; DB0140A025C40C0600F9F3CF /* MastodonPinBasedAuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewController.swift; sourceTree = ""; }; DB0140A725C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift; sourceTree = ""; }; DB0140AD25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPinBasedAuthenticationViewModel.swift; sourceTree = ""; }; @@ -650,7 +714,6 @@ DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; - DB1E05E0263180F500201847 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; DB1E346725F518E20079D7DF /* CategoryPickerSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CategoryPickerSection.swift; sourceTree = ""; }; DB1E347725F519300079D7DF /* PickServerItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickServerItem.swift; sourceTree = ""; }; DB1FD43525F26899004CFCFC /* MastodonPickServerViewModel+LoadIndexedServerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonPickServerViewModel+LoadIndexedServerState.swift"; sourceTree = ""; }; @@ -724,6 +787,14 @@ DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; + DB68045A2636DC6A00430867 /* MastodonNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonNotification.swift; sourceTree = ""; }; + DB68047F2637CD4C00430867 /* AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + DB6804812637CD4C00430867 /* AppShared.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppShared.h; sourceTree = ""; }; + DB6804822637CD4C00430867 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + DB6804912637CD8700430867 /* AppName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppName.swift; sourceTree = ""; }; + DB6804D02637CE4700430867 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; + DB6804FC2637CFEC00430867 /* AppSecret.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSecret.swift; sourceTree = ""; }; + DB68053E2638011000430867 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NotificationService.entitlements; sourceTree = ""; }; DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSKeyValueObservation.swift; sourceTree = ""; }; DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatusBarStyleNavigationController.swift; sourceTree = ""; }; DB68A05C25E9055900CFDF14 /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; @@ -732,10 +803,8 @@ DB6B351D2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusAttachmentCollectionViewCell.swift; sourceTree = ""; }; DB6C8C0E25F0A6AE00AAA452 /* Mastodon+Entity+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+Entity+Error.swift"; sourceTree = ""; }; DB6D1B23263684C600ACB481 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; - DB6D1B2A2636852000ACB481 /* AppSharedName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSharedName.swift; sourceTree = ""; }; DB6D1B3C2636857500ACB481 /* AppearancePreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePreference.swift; sourceTree = ""; }; DB6D1B43263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Mastodon+API+Subscriptions+Policy.swift"; sourceTree = ""; }; - DB6D9F222635195E008423CD /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationService+Decrypt.swift"; sourceTree = ""; }; DB6D9F4826353FD6008423CD /* Subscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subscription.swift; sourceTree = ""; }; DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionAlerts.swift; sourceTree = ""; }; @@ -888,6 +957,7 @@ 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */, DBB525082611EAC0002F1F29 /* Tabman in Frameworks */, 5D526FE225BE9AC400460CB9 /* MastodonSDK in Frameworks */, + DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DB5086B825CC0D6400C2C187 /* Kingfisher in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, @@ -895,6 +965,7 @@ DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, + DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -915,10 +986,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047C2637CD4C00430867 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6805102637D0F800430867 /* KeychainAccess in Frameworks */, + D86919F5080C3F228CCD17D1 /* Pods_Mastodon_AppShared.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EB25C10FD0008580ED /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -937,6 +1018,7 @@ DB00CA972632DDB600A54956 /* CommonOSLog in Frameworks */, DB6D9F42263527CE008423CD /* AlamofireImage in Frameworks */, DBF8AE862632992800C9C23C /* Base85 in Frameworks */, + DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */, 7D603B3DC600BB75956FAD7D /* Pods_Mastodon_NotificationService.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1021,6 +1103,8 @@ 9780A4C98FFC65B32B50D1C0 /* Pods-MastodonTests.release.xcconfig */, 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */, B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */, + D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */, + B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -1351,6 +1435,7 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */, 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */, 79D7EB88A6A8B34DFDFC96FC /* Pods_Mastodon_NotificationService.framework */, + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */, ); name = Frameworks; sourceTree = ""; @@ -1453,8 +1538,6 @@ children = ( DB427DD525BAA00100D1B89D /* AppDelegate.swift */, DB427DD725BAA00100D1B89D /* SceneDelegate.swift */, - DB1E05E0263180F500201847 /* AppSecret.swift */, - DB6D1B2A2636852000ACB481 /* AppSharedName.swift */, DB427DDB25BAA00100D1B89D /* Main.storyboard */, DB427DE025BAA00100D1B89D /* LaunchScreen.storyboard */, DB68A05C25E9055900CFDF14 /* Settings.bundle */, @@ -1485,6 +1568,7 @@ DB89B9EF25C10FD0008580ED /* CoreDataStack */, DB89B9FC25C10FD0008580ED /* CoreDataStackTests */, DBF8AE14263293E400C9C23C /* NotificationService */, + DB6804802637CD4C00430867 /* AppShared */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1501,6 +1585,7 @@ DB89B9EE25C10FD0008580ED /* CoreDataStack.framework */, DB89B9F625C10FD0008580ED /* CoreDataStackTests.xctest */, DBF8AE13263293E400C9C23C /* NotificationService.appex */, + DB68047F2637CD4C00430867 /* AppShared.framework */, ); name = Products; sourceTree = ""; @@ -1628,6 +1713,18 @@ path = View; sourceTree = ""; }; + DB6804802637CD4C00430867 /* AppShared */ = { + isa = PBXGroup; + children = ( + DB6804812637CD4C00430867 /* AppShared.h */, + DB6804822637CD4C00430867 /* Info.plist */, + DB6804912637CD8700430867 /* AppName.swift */, + DB6804FC2637CFEC00430867 /* AppSecret.swift */, + DB6804D02637CE4700430867 /* UserDefaults.swift */, + ); + path = AppShared; + sourceTree = ""; + }; DB68A03825E900CC00CFDF14 /* Share */ = { isa = PBXGroup; children = ( @@ -1659,14 +1756,6 @@ path = MastodonSDK; sourceTree = ""; }; - DB6D9F2926351961008423CD /* Extension */ = { - isa = PBXGroup; - children = ( - DB6D9F222635195E008423CD /* String.swift */, - ); - path = Extension; - sourceTree = ""; - }; DB72602125E36A2500235243 /* ServerRules */ = { isa = PBXGroup; children = ( @@ -2108,9 +2197,10 @@ DBF8AE14263293E400C9C23C /* NotificationService */ = { isa = PBXGroup; children = ( + DB68053E2638011000430867 /* NotificationService.entitlements */, DBF8AE15263293E400C9C23C /* NotificationService.swift */, DB6D9F3426351B7A008423CD /* NotificationService+Decrypt.swift */, - DB6D9F2926351961008423CD /* Extension */, + DB68045A2636DC6A00430867 /* MastodonNotification.swift */, DBF8AE17263293E400C9C23C /* Info.plist */, ); path = NotificationService; @@ -2119,6 +2209,14 @@ /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ + DB68047A2637CD4C00430867 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804832637CD4C00430867 /* AppShared.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9E925C10FD0008580ED /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; @@ -2148,6 +2246,8 @@ dependencies = ( DB89BA0225C10FD0008580ED /* PBXTargetDependency */, DBF8AE19263293E400C9C23C /* PBXTargetDependency */, + DB6804852637CD4C00430867 /* PBXTargetDependency */, + DB6804CA2637CE3000430867 /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -2206,6 +2306,28 @@ productReference = DB427DF325BAA00100D1B89D /* MastodonUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + DB68047E2637CD4C00430867 /* AppShared */ = { + isa = PBXNativeTarget; + buildConfigurationList = DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */; + buildPhases = ( + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */, + DB68047A2637CD4C00430867 /* Headers */, + DB68047B2637CD4C00430867 /* Sources */, + DB68047C2637CD4C00430867 /* Frameworks */, + DB68047D2637CD4C00430867 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AppShared; + packageProductDependencies = ( + DB68050F2637D0F800430867 /* KeychainAccess */, + ); + productName = AppShared; + productReference = DB68047F2637CD4C00430867 /* AppShared.framework */; + productType = "com.apple.product-type.framework"; + }; DB89B9ED25C10FD0008580ED /* CoreDataStack */ = { isa = PBXNativeTarget; buildConfigurationList = DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */; @@ -2214,10 +2336,12 @@ DB89B9EA25C10FD0008580ED /* Sources */, DB89B9EB25C10FD0008580ED /* Frameworks */, DB89B9EC25C10FD0008580ED /* Resources */, + DB68052A2637D7DD00430867 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + DB6805292637D7DD00430867 /* PBXTargetDependency */, ); name = CoreDataStack; productName = CoreDataStack; @@ -2251,10 +2375,12 @@ DBF8AE0F263293E400C9C23C /* Sources */, DBF8AE10263293E400C9C23C /* Frameworks */, DBF8AE11263293E400C9C23C /* Resources */, + DB6804A92637CDCC00430867 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( + DB6804A82637CDCC00430867 /* PBXTargetDependency */, ); name = NotificationService; packageProductDependencies = ( @@ -2287,6 +2413,10 @@ CreatedOnToolsVersion = 12.4; TestTargetID = DB427DD125BAA00100D1B89D; }; + DB68047E2637CD4C00430867 = { + CreatedOnToolsVersion = 12.4; + LastSwiftMigration = 1240; + }; DB89B9ED25C10FD0008580ED = { CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1240; @@ -2321,6 +2451,7 @@ DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -2332,6 +2463,7 @@ DB89B9ED25C10FD0008580ED /* CoreDataStack */, DB89B9F525C10FD0008580ED /* CoreDataStackTests */, DBF8AE12263293E400C9C23C /* NotificationService */, + DB68047E2637CD4C00430867 /* AppShared */, ); }; /* End PBXProject section */ @@ -2365,6 +2497,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047D2637CD4C00430867 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EC25C10FD0008580ED /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2472,6 +2611,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + C6B7D3A8ACD77F6620D0E0AD /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Mastodon-AppShared-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; DB3D100425BAA71500EAA174 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -2772,6 +2933,7 @@ DB0140A825C40C1500F9F3CF /* MastodonPinBasedAuthenticationViewModelNavigationDelegateShim.swift in Sources */, DB1FD45025F26FA1004CFCFC /* MastodonPickServerViewModel+Diffable.swift in Sources */, DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */, + DB68046C2636DC9E00430867 /* MastodonNotification.swift in Sources */, DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */, DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */, DB6D9F57263577D2008423CD /* APIService+CoreData+Setting.swift in Sources */, @@ -2780,7 +2942,6 @@ DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */, DB98338725C945ED00AD9700 /* Strings.swift in Sources */, 2D7867192625B77500211898 /* NotificationItem.swift in Sources */, - DB6D1B2B2636852000ACB481 /* AppSharedName.swift in Sources */, DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */, DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */, 2D5A3D0325CF8742002347D6 /* ControlContainableScrollViews.swift in Sources */, @@ -2853,7 +3014,6 @@ 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, - DB1E05E1263180F500201847 /* AppSecret.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */, @@ -2877,6 +3037,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DB68047B2637CD4C00430867 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */, + DB6804922637CD8700430867 /* AppName.swift in Sources */, + DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB89B9EA25C10FD0008580ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2898,7 +3068,6 @@ 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */, DB4481B325EE16D000BEFB67 /* PollOption.swift in Sources */, DB89BA4425C1165F008580ED /* Managed.swift in Sources */, - DB6D1B312636853100ACB481 /* AppSharedName.swift in Sources */, 2D6125472625436B00299647 /* Notification.swift in Sources */, DB89BA4325C1165F008580ED /* NetworkUpdatable.swift in Sources */, DB8AF56825C13E2A002E6C99 /* HomeTimelineIndex.swift in Sources */, @@ -2926,11 +3095,10 @@ buildActionMask = 2147483647; files = ( DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */, - DB6D9F232635195E008423CD /* String.swift in Sources */, - DBE54AB92636C87B004E7C0B /* AppSharedName.swift in Sources */, + DB68045B2636DC6A00430867 /* MastodonNotification.swift in Sources */, DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */, - DB6D9F3B26352019008423CD /* AppSecret.swift in Sources */, DB6D9F3526351B7A008423CD /* NotificationService+Decrypt.swift in Sources */, + DB6804662636DC9000430867 /* String.swift in Sources */, DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2948,6 +3116,26 @@ target = DB427DD125BAA00100D1B89D /* Mastodon */; targetProxy = DB427DF425BAA00100D1B89D /* PBXContainerItemProxy */; }; + DB6804852637CD4C00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804842637CD4C00430867 /* PBXContainerItemProxy */; + }; + DB6804A82637CDCC00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804A72637CDCC00430867 /* PBXContainerItemProxy */; + }; + DB6804CA2637CE3000430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6804C92637CE3000430867 /* PBXContainerItemProxy */; + }; + DB6805292637D7DD00430867 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB68047E2637CD4C00430867 /* AppShared */; + targetProxy = DB6805282637D7DD00430867 /* PBXContainerItemProxy */; + }; DB89B9F925C10FD0008580ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB89B9ED25C10FD0008580ED /* CoreDataStack */; @@ -3126,7 +3314,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2E1F6A67FDF9771D3E064FDC /* Pods-Mastodon.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3154,7 +3341,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 75E3471C898DDD9631729B6E /* Pods-Mastodon.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; @@ -3259,6 +3445,65 @@ }; name = Release; }; + DB6804892637CD4C00430867 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + DB68048A2637CD4C00430867 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 7LFDZ96332; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = AppShared/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon.AppShared; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; DB89BA0625C10FD0008580ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3360,10 +3605,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = 8ED8C4B1F1BA2DCFF2926BB1 /* Pods-Mastodon-NotificationService.debug.xcconfig */; buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3381,10 +3626,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = B44342AC2B6585F8295F1DDF /* Pods-Mastodon-NotificationService.release.xcconfig */; buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = NotificationService/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -3437,6 +3682,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DB6804882637CD4C00430867 /* Build configuration list for PBXNativeTarget "AppShared" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DB6804892637CD4C00430867 /* Debug */, + DB68048A2637CD4C00430867 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DB89BA0525C10FD0008580ED /* Build configuration list for PBXNativeTarget "CoreDataStack" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -3523,6 +3777,14 @@ minimumVersion = 6.1.0; }; }; + DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.2.2; + }; + }; DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/UITextView-Placeholder"; @@ -3602,6 +3864,11 @@ package = DB5086B625CC0D6400C2C187 /* XCRemoteSwiftPackageReference "Kingfisher" */; productName = Kingfisher; }; + DB68050F2637D0F800430867 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; DB6D9F41263527CE008423CD /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 3e5f0c5d3..083bcfbbe 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,10 +4,15 @@ SchemeUserState + AppShared.xcscheme_^#shared#^_ + + orderHint + 18 + CoreDataStack.xcscheme_^#shared#^_ orderHint - 13 + 17 Mastodon - RTL.xcscheme_^#shared#^_ @@ -27,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 14 + 18 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index a8c0df347..47136a2c7 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -55,6 +55,15 @@ "version": "0.1.1" } }, + { + "package": "KeychainAccess", + "repositoryURL": "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state": { + "branch": null, + "revision": "84e546727d66f1adc5439debad16270d0fdd04e7", + "version": "4.2.2" + } + }, { "package": "Kingfisher", "repositoryURL": "https://github.com/onevcat/Kingfisher.git", diff --git a/Mastodon/Extension/String.swift b/Mastodon/Extension/String.swift index 87028ffdf..bf70c8937 100644 --- a/Mastodon/Extension/String.swift +++ b/Mastodon/Extension/String.swift @@ -16,3 +16,25 @@ extension String { self = self.capitalizingFirstLetter() } } + +extension String { + static func normalize(base64String: String) -> String { + let base64 = base64String + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + .padding() + return base64 + } + + private func padding() -> String { + let remainder = self.count % 4 + if remainder > 0 { + return self.padding( + toLength: self.count + 4 - remainder, + withPad: "=", + startingAt: 0 + ) + } + return self + } +} diff --git a/Mastodon/Extension/UserDefaults.swift b/Mastodon/Extension/UserDefaults.swift index 5e067bbe9..619d6c250 100644 --- a/Mastodon/Extension/UserDefaults.swift +++ b/Mastodon/Extension/UserDefaults.swift @@ -6,10 +6,7 @@ // import Foundation - -extension UserDefaults { - static let shared = UserDefaults(suiteName: AppSharedName.groupID)! -} +import AppShared extension UserDefaults { diff --git a/Mastodon/Preference/AppearancePreference.swift b/Mastodon/Preference/AppearancePreference.swift index 8f2818c39..78cf3d332 100644 --- a/Mastodon/Preference/AppearancePreference.swift +++ b/Mastodon/Preference/AppearancePreference.swift @@ -14,7 +14,7 @@ extension UserDefaults { register(defaults: [#function: UIUserInterfaceStyle.unspecified.rawValue]) return UIUserInterfaceStyle(rawValue: integer(forKey: #function)) ?? .unspecified } - set { UserDefaults.shared[#function] = newValue.rawValue } + set { self[#function] = newValue.rawValue } } } diff --git a/Mastodon/Preference/NotificationPreference.swift b/Mastodon/Preference/NotificationPreference.swift index b634d77f3..289cd1fdf 100644 --- a/Mastodon/Preference/NotificationPreference.swift +++ b/Mastodon/Preference/NotificationPreference.swift @@ -14,7 +14,7 @@ extension UserDefaults { register(defaults: [#function: 0]) return integer(forKey: #function) } - set { UserDefaults.shared[#function] = newValue } + set { self[#function] = newValue } } } diff --git a/Mastodon/Service/APIService/APIService+Subscriptions.swift b/Mastodon/Service/APIService/APIService+Subscriptions.swift index 3e2d2a0aa..ceaff45fa 100644 --- a/Mastodon/Service/APIService/APIService+Subscriptions.swift +++ b/Mastodon/Service/APIService/APIService+Subscriptions.swift @@ -29,7 +29,7 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful ", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: create subscription successful %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.endpoint) let managedObjectContext = self.backgroundManagedObjectContext return managedObjectContext.performChanges { @@ -45,7 +45,8 @@ extension APIService { .setFailureType(to: Error.self) .map { _ in return response } .eraseToAnyPublisher() - }.eraseToAnyPublisher() + } + .eraseToAnyPublisher() } func cancelSubscription( diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift index fb6879da9..0c23eab6e 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Setting.swift @@ -27,35 +27,50 @@ extension APIService.CoreData { }() if let oldSetting = oldSetting { + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: oldSetting) return (oldSetting, false) } else { let setting = Setting.insert( into: managedObjectContext, property: property ) - let policies: [Mastodon.API.Subscriptions.Policy] = [ - .all, - .followed, - .follower, - .none - ] - let now = Date() - policies.forEach { policy in - let (subscription, _) = createOrFetchSubscription( - into: managedObjectContext, - setting: setting, - policy: policy - ) - if policy == .all { - subscription.update(activedAt: now) - } else { - subscription.update(activedAt: now.addingTimeInterval(-10)) - } - } - - + setupSettingSubscriptions(managedObjectContext: managedObjectContext, setting: setting) return (setting, true) } } } + +extension APIService.CoreData { + + static func setupSettingSubscriptions( + managedObjectContext: NSManagedObjectContext, + setting: Setting + ) { + guard (setting.subscriptions ?? Set()).isEmpty else { return } + + let now = Date() + let policies: [Mastodon.API.Subscriptions.Policy] = [ + .all, + .followed, + .follower, + .none + ] + policies.forEach { policy in + let (subscription, _) = createOrFetchSubscription( + into: managedObjectContext, + setting: setting, + policy: policy + ) + if policy == .all { + subscription.update(activedAt: now) + } else { + subscription.update(activedAt: now.addingTimeInterval(-10)) + } + } + + // trigger setting update + setting.didUpdate(at: now) + } + +} diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift index 5e42a8abe..6eebc9e56 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Subscriptions.swift @@ -27,6 +27,7 @@ extension APIService.CoreData { }() if let oldSubscription = oldSubscription { + oldSubscription.setting = setting return (oldSubscription, false) } else { let subscriptionProperty = Subscription.Property(policyRaw: policy.rawValue) diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 526c35883..90680e783 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -11,6 +11,7 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import AppShared final class NotificationService { @@ -32,6 +33,16 @@ final class NotificationService { ) { self.authenticationService = authenticationService + authenticationService.mastodonAuthentications + .sink(receiveValue: { [weak self] mastodonAuthentications in + guard let self = self else { return } + + // request permission when sign-in + guard !mastodonAuthentications.isEmpty else { return } + self.requestNotificationPermission() + }) + .store(in: &disposeBag) + deviceToken .receive(on: DispatchQueue.main) .sink { [weak self] deviceToken in @@ -83,13 +94,7 @@ extension NotificationService { } return _notificationSubscription } - - static func createRandomAuthBytes() -> Data { - let byteCount = 16 - var bytes = Data(count: byteCount) - _ = bytes.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, byteCount, $0.baseAddress!) } - return bytes - } + } extension NotificationService { @@ -120,7 +125,7 @@ extension NotificationService.NotificationViewModel { let appSecret = AppSecret.default let endpoint = appSecret.notificationEndpoint + "/" + deviceToken - let p256dh = appSecret.uncompressionNotificationPublicKeyData + let p256dh = appSecret.notificationPublicKey.x963Representation let auth = appSecret.notificationAuth let query = Mastodon.API.Subscriptions.CreateSubscriptionQuery( diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index e097b4dc4..8683b3972 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-4-25. // +import os.log import UIKit import Combine import CoreDataStack @@ -108,16 +109,17 @@ final class SettingService { } .store(in: &disposeBag) - Publishers.CombineLatest( + Publishers.CombineLatest3( notificationService.deviceToken, - currentSetting + currentSetting.eraseToAnyPublisher(), + authenticationService.activeMastodonAuthenticationBox ) - .compactMap { [weak self] deviceToken, setting -> AnyPublisher, Error>? in + .compactMap { [weak self] deviceToken, setting, activeMastodonAuthenticationBox -> AnyPublisher, Error>? in guard let self = self else { return nil } - guard let apiService = self.apiService else { return nil } guard let deviceToken = deviceToken else { return nil } - guard let authenticationBox = self.authenticationService?.activeMastodonAuthenticationBox.value else { return nil } guard let setting = setting else { return nil } + guard let authenticationBox = activeMastodonAuthenticationBox else { return nil } + guard let subscription = setting.activeSubscription else { return nil } guard setting.domain == authenticationBox.domain, @@ -142,21 +144,30 @@ final class SettingService { queryData: queryData, mastodonAuthenticationBox: authenticationBox ) - + return apiService.createSubscription( subscriptionObjectID: subscription.objectID, query: query, mastodonAuthenticationBox: authenticationBox ) } - .switchToLatest() - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } + .debounce(for: .seconds(3), scheduler: DispatchQueue.main) // limit subscribe request emit time interval + .sink(receiveValue: { [weak self] publisher in + guard let self = self else { return } + publisher + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe failure: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] subscribe success", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + }) .store(in: &disposeBag) - } } diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index fd4c23004..79017e298 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import AppShared @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -15,11 +16,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + AppSecret.default.register() + // Update app version info. See: `Settings.bundle` UserDefaults.standard.setValue(UIApplication.appVersion(), forKey: "Mastodon.appVersion") UserDefaults.standard.setValue(UIApplication.appBuild(), forKey: "Mastodon.appBundle") -// UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() return true @@ -57,7 +60,28 @@ extension AppDelegate { // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + if let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] present", ((#file as NSString).lastPathComponent), #line, #function) + + } + completionHandler(.banner) + } + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + + } } extension AppContext { diff --git a/Mastodon/Supporting Files/AppSecret.swift b/Mastodon/Supporting Files/AppSecret.swift deleted file mode 100644 index 0a30553a7..000000000 --- a/Mastodon/Supporting Files/AppSecret.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppSecret.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-22. -// - -import Foundation -import CryptoKit -import Keys - -final class AppSecret { - - let notificationEndpoint: String - - let notificationPrivateKey: P256.KeyAgreement.PrivateKey! - let notificationPublicKey: P256.KeyAgreement.PublicKey! - let notificationAuth: Data - - static let `default`: AppSecret = { - return AppSecret() - }() - - init() { - let keys = MastodonKeys() - - #if DEBUG - self.notificationEndpoint = keys.notification_endpoint_debug - let nonce = keys.notification_key_nonce_debug - let auth = keys.notification_key_auth_debug - #else - self.notificationEndpoint = keys.notification_endpoint - let nonce = keys.notification_key_nonce - let auth = keys.notification_key_auth - #endif - - notificationPrivateKey = try! P256.KeyAgreement.PrivateKey(rawRepresentation: Data(base64Encoded: nonce)!) - notificationPublicKey = notificationPrivateKey!.publicKey - notificationAuth = Data(base64Encoded: auth)! - } - - var uncompressionNotificationPublicKeyData: Data { - var data = notificationPublicKey.rawRepresentation - if data.count == 64 { - let prefix: [UInt8] = [0x04] - data = Data(prefix) + data - } - return data - } - -} diff --git a/Mastodon/Supporting Files/AppSharedName.swift b/Mastodon/Supporting Files/AppSharedName.swift deleted file mode 100644 index 3570c68da..000000000 --- a/Mastodon/Supporting Files/AppSharedName.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// AppSharedName.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-4-26. -// - -import Foundation - -enum AppSharedName { - static let groupID = "group.org.joinmastodon.mastodon-temp" -} diff --git a/NotificationService/Extension/String.swift b/NotificationService/Extension/String.swift deleted file mode 100644 index edb162428..000000000 --- a/NotificationService/Extension/String.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// String.swift -// NotificationService -// -// Created by MainasuK Cirno on 2021-4-25. -// - -import Foundation - -extension String { - static func normalize(base64String: String) -> String { - let base64 = base64String - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - .padding() - return base64 - } - - private func padding() -> String { - let remainder = self.count % 4 - if remainder > 0 { - return self.padding( - toLength: self.count + 4 - remainder, - withPad: "=", - startingAt: 0 - ) - } - return self - } -} - diff --git a/NotificationService/MastodonNotification.swift b/NotificationService/MastodonNotification.swift new file mode 100644 index 000000000..f3941b12d --- /dev/null +++ b/NotificationService/MastodonNotification.swift @@ -0,0 +1,35 @@ +// +// MastodonNotification.swift +// NotificationService +// +// Created by MainasuK Cirno on 2021-4-26. +// + +import Foundation + +struct MastodonPushNotification: Codable { + + private let _accessToken: String + var accessToken: String { + return String.normalize(base64String: _accessToken) + } + + let notificationID: Int + let notificationType: String + + let preferredLocale: String? + let icon: String? + let title: String + let body: String + + enum CodingKeys: String, CodingKey { + case _accessToken = "access_token" + case notificationID = "notification_id" + case notificationType = "notification_type" + case preferredLocale = "preferred_locale" + case icon + case title + case body + } + +} diff --git a/NotificationService/NotificationService+Decrypt.swift b/NotificationService/NotificationService+Decrypt.swift index 065863fda..858e7c2c5 100644 --- a/NotificationService/NotificationService+Decrypt.swift +++ b/NotificationService/NotificationService+Decrypt.swift @@ -32,7 +32,13 @@ extension NotificationService { return nil } - guard let plaintext = try? AES.GCM.open(sealedBox, using: key) else { + var _plaintext: Data? + do { + _plaintext = try AES.GCM.open(sealedBox, using: key) + } catch { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: sealedBox open fail %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + } + guard let plaintext = _plaintext else { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: failed to open sealedBox", ((#file as NSString).lastPathComponent), #line, #function) return nil } @@ -65,32 +71,3 @@ extension NotificationService { return info } } - -extension NotificationService { - struct MastodonNotification: Codable { - - private let _accessToken: String - var accessToken: String { - return String.normalize(base64String: _accessToken) - } - - let notificationID: Int - let notificationType: String - - let preferredLocale: String? - let icon: String? - let title: String - let body: String - - enum CodingKeys: String, CodingKey { - case _accessToken = "access_token" - case notificationID = "notification_id" - case notificationType = "notification_type" - case preferredLocale = "preferred_locale" - case icon - case title - case body - } - - } -} diff --git a/NotificationService/NotificationService.entitlements b/NotificationService/NotificationService.entitlements new file mode 100644 index 000000000..d334a5e6d --- /dev/null +++ b/NotificationService/NotificationService.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.joinmastodon.mastodon-temp + + + diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index e20fc23b2..f40f84ce6 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -10,7 +10,7 @@ import CommonOSLog import CryptoKit import AlamofireImage import Base85 -import Keys +import AppShared class NotificationService: UNNotificationServiceExtension { @@ -25,7 +25,8 @@ class NotificationService: UNNotificationServiceExtension { // Modify the notification content here... os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let privateKey = AppSecret.default.notificationPrivateKey! + let privateKey = AppSecret.default.notificationPrivateKey + let auth = AppSecret.default.notificationAuth guard let encodedPayload = bestAttemptContent.userInfo["p"] as? String, let payload = Data(base85Encoded: encodedPayload, options: [], encoding: .z85) else { @@ -48,9 +49,8 @@ class NotificationService: UNNotificationServiceExtension { return } - let auth = AppSecret.default.notificationAuth guard let plaintextData = NotificationService.decrypt(payload: payload, salt: salt, auth: auth, privateKey: privateKey, publicKey: publicKey), - let notification = try? JSONDecoder().decode(MastodonNotification.self, from: plaintextData) else { + let notification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintextData) else { contentHandler(bestAttemptContent) return } @@ -58,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = notification.title bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + bestAttemptContent.userInfo["plaintext"] = plaintextData UserDefaults.shared.notificationBadgeCount += 1 bestAttemptContent.badge = NSNumber(integerLiteral: UserDefaults.shared.notificationBadgeCount) diff --git a/Podfile b/Podfile index bb929277e..ea7075ea8 100644 --- a/Podfile +++ b/Podfile @@ -27,16 +27,16 @@ target 'Mastodon' do end + target 'AppShared' do + + end + end plugin 'cocoapods-keys', { :project => "Mastodon", :keys => [ "notification_endpoint", - "notification_endpoint_debug", - "notification_key_nonce", - "notification_key_nonce_debug", - "notification_key_auth", - "notification_key_auth_debug" + "notification_endpoint_debug" ] } \ No newline at end of file diff --git a/Podfile.lock b/Podfile.lock index d34a2ada5..e341a2420 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -30,6 +30,6 @@ SPEC CHECKSUMS: SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: b99204f58cb11d471cfad7269bbf0abb853dc953 +PODFILE CHECKSUM: a8dbae22e6e0bfb84f7db59aef1aa1716793d287 COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index d3cddb071..71194684d 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ arch -x86_64 pod install - [CryptoSwift](https://github.com/krzyzanowskim/CryptoSwift) - [DateToolSwift](https://github.com/MatthewYork/DateTools) - [Kanna](https://github.com/tid-kijyun/Kanna) +- [KeychainAccess](https://github.com/kishikawakatsumi/KeychainAccess.git) - [Kingfisher](https://github.com/onevcat/Kingfisher) - [SwiftGen](https://github.com/SwiftGen/SwiftGen) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) From d4552332b32d5504b14ed113d26aedd58ea37756 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 16:29:10 +0800 Subject: [PATCH 330/400] chore: update CI script --- .github/scripts/setup.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh index e1411fb50..0c2612d51 100755 --- a/.github/scripts/setup.sh +++ b/.github/scripts/setup.sh @@ -1,4 +1,9 @@ #!/bin/bash sudo gem install cocoapods-keys -pod install \ No newline at end of file + +# stub keys. DO NOT use in production +pod keys set notification_endpoint "" +pod keys set notification_endpoint_debug "" + +pod install From d03346c0de35bd4c97441fd209c76a2b4be58c55 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 16:54:23 +0800 Subject: [PATCH 331/400] fix: add followrequest cell action --- .../Section/NotificationSection.swift | 12 +++++++ .../NotificationViewController.swift | 8 +++++ .../Notification/NotificationViewModel.swift | 33 +++++++++++++++++++ .../NotificationTableViewCell.swift | 6 ++-- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 57c755818..d2df0a78b 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -91,6 +91,18 @@ extension NotificationSection { cell.actionLabel.text = actionText + " · " + timeText } .store(in: &cell.disposeBag) + cell.acceptButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, acceptButtonDidPressed: cell.acceptButton) + } + .store(in: &cell.disposeBag) + cell.rejectButton.publisher(for: .touchUpInside) + .sink { [weak cell] _ in + guard let cell = cell else { return } + cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.acceptButton) + } + .store(in: &cell.disposeBag) cell.actionImageBackground.backgroundColor = color cell.actionLabel.text = actionText + " · " + timeText cell.nameLabel.text = notification.account.displayName.isEmpty ? notification.account.username : notification.account.displayName diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc639..f3b143f52 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -205,6 +205,14 @@ extension NotificationViewController: ContentOffsetAdjustableTimelineViewControl // MARK: - NotificationTableViewCellDelegate extension NotificationViewController: NotificationTableViewCellDelegate { + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) { + viewModel.acceptFollowRequest(notification: notification) + } + + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) { + viewModel.rejectFollowRequest(notification: notification) + } + func userAvatarDidPressed(notification: MastodonNotification) { let viewModel = ProfileViewModel(context: context, optionalMastodonUser: notification.account) DispatchQueue.main.async { diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index e026af732..f60e3d76d 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -12,6 +12,7 @@ import Foundation import GameplayKit import MastodonSDK import UIKit +import OSLog final class NotificationViewModel: NSObject { var disposeBag = Set() @@ -120,6 +121,38 @@ final class NotificationViewModel: NSObject { } .store(in: &disposeBag) } + + func acceptFollowRequest(notification: MastodonNotification) { + guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } + context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: accept FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } + + func rejectFollowRequest(notification: MastodonNotification) { + guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } + context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + .sink { [weak self] completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: reject FollowRequest fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + self?.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + } receiveValue: { _ in + + } + .store(in: &disposeBag) + } } extension NotificationViewModel { diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index dc5c4c19c..c049b961e 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -21,9 +21,9 @@ protocol NotificationTableViewCellDelegate: AnyObject { func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, statusView: StatusView, playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) -// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) -// -// func notificationStatusTableViewCell(_ cell: NotificationStatusTableViewCell, notification: MastodonNotification, denyButtonDidPressed button: UIButton) + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, acceptButtonDidPressed button: UIButton) + + func notificationTableViewCell(_ cell: NotificationTableViewCell, notification: MastodonNotification, rejectButtonDidPressed button: UIButton) } From ed9c2ddd8f015fe2f3b2b97366730c890b4e8c7c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 17:27:03 +0800 Subject: [PATCH 332/400] feat: handle notification response --- Mastodon/Extension/UIViewController.swift | 47 +++++++++++++++++++ .../Scene/MainTab/MainTabBarController.swift | 24 ++++++++-- .../NotificationViewController.swift | 5 ++ ...otificationViewModel+LoadLatestState.swift | 34 +++++++------- .../APIService/APIService+Notification.swift | 16 +++++++ Mastodon/Service/NotificationService.swift | 13 ++++- Mastodon/State/AppContext.swift | 1 + Mastodon/Supporting Files/AppDelegate.swift | 33 +++++++++++-- .../API/Mastodon+API+Notifications.swift | 2 +- 9 files changed, 148 insertions(+), 27 deletions(-) diff --git a/Mastodon/Extension/UIViewController.swift b/Mastodon/Extension/UIViewController.swift index c3782fa14..9ebb3a0a8 100644 --- a/Mastodon/Extension/UIViewController.swift +++ b/Mastodon/Extension/UIViewController.swift @@ -46,6 +46,53 @@ extension UIViewController { } +extension UIViewController { + + func viewController(of type: T.Type) -> T? { + if let viewController = self as? T { + return viewController + } + + // UITabBarController + if let tabBarController = self as? UITabBarController { + for tab in tabBarController.viewControllers ?? [] { + if let viewController = tab.viewController(of: type) { + return viewController + } + } + } + + // UINavigationController + if let navigationController = self as? UINavigationController { + for page in navigationController.viewControllers { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // UIPageController + if let pageViewController = self as? UIPageViewController { + for page in pageViewController.viewControllers ?? [] { + if let viewController = page.viewController(of: type) { + return viewController + } + } + } + + // child view controller + for subview in self.view?.subviews ?? [] { + if let childViewController = subview.next as? UIViewController, + let viewController = childViewController.viewController(of: type) { + return viewController + } + } + + return nil + } + +} + extension UIViewController { /// https://bluelemonbits.com/2018/08/26/inserting-cells-at-the-top-of-a-uitableview-with-no-scrolling/ diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 9d609234f..d5905cbb7 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -85,7 +85,6 @@ class MainTabBarController: UITabBarController { extension MainTabBarController { - open override var childForStatusBarStyle: UIViewController? { return selectedViewController } @@ -156,9 +155,26 @@ extension MainTabBarController { } .store(in: &disposeBag) - #if DEBUG - // selectedIndex = 3 - #endif + // handle push notification. toggle entry when finish fetch latest notification + context.notificationService.hasUnreadPushNotification + .receive(on: DispatchQueue.main) + .sink { [weak self] hasUnreadPushNotification in + guard let self = self else { return } + guard let notificationViewController = self.notificationViewController else { return } + + let image = hasUnreadPushNotification ? UIImage(systemName: "bell.badge.fill")! : UIImage(systemName: "bell.fill")! + notificationViewController.tabBarItem.image = image + notificationViewController.navigationController?.tabBarItem.image = image + } + .store(in: &disposeBag) } } + +extension MainTabBarController { + + var notificationViewController: NotificationViewController? { + return viewController(of: NotificationViewController.self) + } + +} diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 57b5dc639..b27c45817 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -88,6 +88,11 @@ extension NotificationViewController { tableView.deselectRow(with: transitionCoordinator, animated: animated) + // fetch latest if has unread push notification + if context.notificationService.hasUnreadPushNotification.value { + viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self) + } + // needs trigger manually after onboarding dismiss setNeedsStatusBarAppearanceUpdate() } diff --git a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift index 0e6b0d622..b9d60ae7c 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel+LoadLatestState.swift @@ -61,23 +61,25 @@ extension NotificationViewModel.LoadLatestState { query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox ) - .sink { completion in - switch completion { - case .failure(let error): - viewModel.isFetchingLatestNotification.value = false - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) - case .finished: - // handle isFetchingLatestTimeline in fetch controller delegate - break - } - - stateMachine.enter(Idle.self) - } receiveValue: { response in - if response.value.isEmpty { - viewModel.isFetchingLatestNotification.value = false - } + .sink { completion in + switch completion { + case .failure(let error): + viewModel.isFetchingLatestNotification.value = false + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch notification failed. %s", (#file as NSString).lastPathComponent, #line, #function, error.localizedDescription) + case .finished: + // toggle unread state + viewModel.context.notificationService.hasUnreadPushNotification.value = false + // handle isFetchingLatestTimeline in fetch controller delegate + break } - .store(in: &viewModel.disposeBag) + + stateMachine.enter(Idle.self) + } receiveValue: { response in + if response.value.isEmpty { + viewModel.isFetchingLatestNotification.value = false + } + } + .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index ee8f5186c..cbc0f9ed7 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -64,4 +64,20 @@ extension APIService { } .eraseToAnyPublisher() } + + func notification( + notificationID: Mastodon.Entity.Notification.ID, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let domain = mastodonAuthenticationBox.domain + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Notifications.getNotification( + session: session, + domain: domain, + notificationID: notificationID, + authorization: authorization + ) + } + } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 90680e783..bfd96df79 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -20,6 +20,7 @@ final class NotificationService { let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.NotificationService.working-queue") // input + weak var apiService: APIService? weak var authenticationService: AuthenticationService? let isNotificationPermissionGranted = CurrentValueSubject(false) let deviceToken = CurrentValueSubject(nil) @@ -27,10 +28,13 @@ final class NotificationService { // output /// [Token: UserID] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] + let hasUnreadPushNotification = CurrentValueSubject(false) init( + apiService: APIService, authenticationService: AuthenticationService ) { + self.apiService = apiService self.authenticationService = authenticationService authenticationService.mastodonAuthentications @@ -94,9 +98,15 @@ extension NotificationService { } return _notificationSubscription } - + + func handlePushNotification(notificationID: Mastodon.Entity.Notification.ID) { + hasUnreadPushNotification.value = true + } + } +// MARK: - NotificationViewModel + extension NotificationService { final class NotificationViewModel { @@ -141,4 +151,5 @@ extension NotificationService.NotificationViewModel { return query } + } diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 0c40ff127..93287f6eb 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -61,6 +61,7 @@ class AppContext: ObservableObject { apiService: _apiService ) let _notificationService = NotificationService( + apiService: _apiService, authenticationService: _authenticationService ) notificationService = _notificationService diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 79017e298..0cca6ddf5 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -7,6 +7,7 @@ import os.log import UIKit +import UserNotifications import AppShared @main @@ -66,12 +67,15 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) - if let plaintext = notification.request.content.userInfo["plaintext"] as? Data, - let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] present", ((#file as NSString).lastPathComponent), #line, #function) - + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: notification) else { + completionHandler([]) + return } - completionHandler(.banner) + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handlePushNotification(notificationID: notificationID) + completionHandler([.sound]) } func userNotificationCenter( @@ -81,6 +85,25 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification]", ((#file as NSString).lastPathComponent), #line, #function) + guard let mastodonPushNotification = AppDelegate.mastodonPushNotification(from: response.notification) else { + completionHandler() + return + } + + let notificationID = String(mastodonPushNotification.notificationID) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + appContext.notificationService.handlePushNotification(notificationID: notificationID) + + completionHandler() + } + + private static func mastodonPushNotification(from notification: UNNotification) -> MastodonPushNotification? { + guard let plaintext = notification.request.content.userInfo["plaintext"] as? Data, + let mastodonPushNotification = try? JSONDecoder().decode(MastodonPushNotification.self, from: plaintext) else { + return nil + } + + return mastodonPushNotification } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift index b0ab13edb..c6b56c9e9 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Notifications.swift @@ -67,7 +67,7 @@ extension Mastodon.API.Notifications { public static func getNotification( session: URLSession, domain: String, - notificationID: String, + notificationID: Mastodon.Entity.Notification.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.get( From d66491b7264542a840741a68d3cacfaaaa2d55f0 Mon Sep 17 00:00:00 2001 From: ihugo Date: Tue, 27 Apr 2021 17:44:01 +0800 Subject: [PATCH 333/400] feat: add prefetching feaature for reporting --- .../Scene/Report/ReportViewController.swift | 10 +++++ .../Scene/Report/ReportViewModel+Data.swift | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/Mastodon/Scene/Report/ReportViewController.swift b/Mastodon/Scene/Report/ReportViewController.swift index dea962dca..b0c6ddcc9 100644 --- a/Mastodon/Scene/Report/ReportViewController.swift +++ b/Mastodon/Scene/Report/ReportViewController.swift @@ -68,6 +68,7 @@ class ReportViewController: UIViewController, NeedsDependency { tableView.backgroundColor = .clear tableView.translatesAutoresizingMaskIntoConstraints = false tableView.delegate = self + tableView.prefetchDataSource = self tableView.allowsMultipleSelection = true return tableView }() @@ -310,6 +311,7 @@ class ReportViewController: UIViewController, NeedsDependency { } } +// MARK: - UITableViewDelegate extension ReportViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { @@ -326,6 +328,14 @@ extension ReportViewController: UITableViewDelegate { } } +// MARK: - UITableViewDataSourcePrefetching +extension ReportViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + viewModel.prefetchData(prefetchRowsAt: indexPaths) + } +} + +// MARK: - UITextViewDelegate extension ReportViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { self.comment.send(textView.text) diff --git a/Mastodon/Scene/Report/ReportViewModel+Data.swift b/Mastodon/Scene/Report/ReportViewModel+Data.swift index da22a4b95..df95cb002 100644 --- a/Mastodon/Scene/Report/ReportViewModel+Data.swift +++ b/Mastodon/Scene/Report/ReportViewModel+Data.swift @@ -97,4 +97,42 @@ extension ReportViewModel { } .store(in: &disposeBag) } + + func prefetchData(prefetchRowsAt indexPaths: [IndexPath]) { + guard let diffableDataSource = diffableDataSource else { return } + + // prefetch reply status + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } + let domain = activeMastodonAuthenticationBox.domain + + var statusObjectIDs: [NSManagedObjectID] = [] + for indexPath in indexPaths { + let item = diffableDataSource.itemIdentifier(for: indexPath) + switch item { + case .reportStatus(let objectID, _): + statusObjectIDs.append(objectID) + default: + continue + } + } + + let backgroundManagedObjectContext = context.backgroundManagedObjectContext + backgroundManagedObjectContext.perform { [weak self] in + guard let self = self else { return } + for objectID in statusObjectIDs { + let status = backgroundManagedObjectContext.object(with: objectID) as! Status + guard let replyToID = status.inReplyToID, status.replyTo == nil else { + // skip + continue + } + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: status.objectID, + statusID: status.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) + } + } + } } From 125f6d0a277a9dbf8a41eb0cae1f01d5156534ea Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 17:45:11 +0800 Subject: [PATCH 334/400] feat: show received notification status --- .../Scene/MainTab/MainTabBarController.swift | 10 +++++ .../Scene/Thread/RemoteThreadViewModel.swift | 39 +++++++++++++++++++ .../APIService/APIService+Notification.swift | 28 +++++++++++++ Mastodon/Service/NotificationService.swift | 1 + Mastodon/Supporting Files/AppDelegate.swift | 6 ++- 5 files changed, 83 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index d5905cbb7..5fd4c8256 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -167,6 +167,16 @@ extension MainTabBarController { notificationViewController.navigationController?.tabBarItem.image = image } .store(in: &disposeBag) + + context.notificationService.requestRevealNotificationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] notificationID in + guard let self = self else { return } + self.coordinator.switchToTabBar(tab: .notification) + let threadViewModel = RemoteThreadViewModel(context: self.context, notificationID: notificationID) + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: nil, transition: .show) + } + .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift index e79c355cf..e6e111018 100644 --- a/Mastodon/Scene/Thread/RemoteThreadViewModel.swift +++ b/Mastodon/Scene/Thread/RemoteThreadViewModel.swift @@ -47,4 +47,43 @@ final class RemoteThreadViewModel: ThreadViewModel { } .store(in: &disposeBag) } + + // FIXME: multiple account supports + init(context: AppContext, notificationID: Mastodon.Entity.Notification.ID) { + super.init(context: context, optionalStatus: nil) + + guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let domain = activeMastodonAuthenticationBox.domain + context.apiService.notification( + notificationID: notificationID, + mastodonAuthenticationBox: activeMastodonAuthenticationBox + ) + .retry(3) + .sink { completion in + switch completion { + case .failure(let error): + // TODO: handle error + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetch failed: %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: remote notification %s fetched", ((#file as NSString).lastPathComponent), #line, #function, notificationID) + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + guard let statusID = response.value.status?.id else { return } + + let managedObjectContext = context.managedObjectContext + let request = Status.sortedFetchRequest + request.fetchLimit = 1 + request.predicate = Status.predicate(domain: domain, id: statusID) + guard let status = managedObjectContext.safeFetch(request).first else { + assertionFailure() + return + } + self.rootItem.value = .root(statusObjectID: status.objectID, attribute: Item.StatusAttribute()) + } + .store(in: &disposeBag) + } + } diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index cbc0f9ed7..590842ce1 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -78,6 +78,34 @@ extension APIService { notificationID: notificationID, authorization: authorization ) + .flatMap { response -> AnyPublisher, Error> in + guard let status = response.value.status else { + return Just(response) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return APIService.Persist.persistStatus( + managedObjectContext: self.backgroundManagedObjectContext, + domain: domain, + query: nil, + response: response.map { _ in [status] }, + persistType: .lookUp, + requestMastodonUserID: nil, + log: OSLog.api + ) + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() } } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index bfd96df79..75a5953ed 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -29,6 +29,7 @@ final class NotificationService { /// [Token: UserID] let notificationSubscriptionDict: [String: NotificationViewModel] = [:] let hasUnreadPushNotification = CurrentValueSubject(false) + let requestRevealNotificationPublisher = PassthroughSubject() init( apiService: APIService, diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 0cca6ddf5..73a13bed1 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -61,6 +61,8 @@ extension AppDelegate { // MARK: - UNUserNotificationCenterDelegate extension AppDelegate: UNUserNotificationCenterDelegate { + + // notification present in the foreground func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -78,6 +80,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { completionHandler([.sound]) } + // response to user action for notification func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -93,7 +96,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) appContext.notificationService.handlePushNotification(notificationID: notificationID) - + appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) completionHandler() } @@ -105,6 +108,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { return mastodonPushNotification } + } extension AppContext { From 37480fe67b86030dc2967682e40db03fe1e57716 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 18:05:29 +0800 Subject: [PATCH 335/400] feat: cancel sign-out user notification subscription when received --- CoreDataStack/Entity/Subscription.swift | 4 ++ Mastodon/Service/NotificationService.swift | 50 ++++++++++++++++++++- Mastodon/Supporting Files/AppDelegate.swift | 4 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CoreDataStack/Entity/Subscription.swift b/CoreDataStack/Entity/Subscription.swift index 6cb1902a1..e1824be1c 100644 --- a/CoreDataStack/Entity/Subscription.swift +++ b/CoreDataStack/Entity/Subscription.swift @@ -80,4 +80,8 @@ extension Subscription { return NSPredicate(format: "%K == %@", #keyPath(Subscription.policyRaw), policyRaw) } + public static func predicate(userToken: String) -> NSPredicate { + return NSPredicate(format: "%K == %@", #keyPath(Subscription.userToken), userToken) + } + } diff --git a/Mastodon/Service/NotificationService.swift b/Mastodon/Service/NotificationService.swift index 75a5953ed..e21a3cff8 100644 --- a/Mastodon/Service/NotificationService.swift +++ b/Mastodon/Service/NotificationService.swift @@ -100,8 +100,56 @@ extension NotificationService { return _notificationSubscription } - func handlePushNotification(notificationID: Mastodon.Entity.Notification.ID) { + func handle(mastodonPushNotification: MastodonPushNotification) { hasUnreadPushNotification.value = true + + // Subscription maybe failed to cancel when sign-out + // Try cancel again if receive that kind push notification + guard let managedObjectContext = authenticationService?.managedObjectContext else { return } + guard let apiService = apiService else { return } + + managedObjectContext.perform { + let subscriptionRequest = NotificationSubscription.sortedFetchRequest + subscriptionRequest.predicate = NotificationSubscription.predicate(userToken: mastodonPushNotification.accessToken) + let subscriptions = managedObjectContext.safeFetch(subscriptionRequest) + + // note: assert setting remove after cancel subscription + guard let subscription = subscriptions.first else { return } + guard let setting = subscription.setting else { return } + let domain = setting.domain + let userID = setting.userID + + let authenticationRequest = MastodonAuthentication.sortedFetchRequest + authenticationRequest.predicate = MastodonAuthentication.predicate(domain: domain, userID: userID) + let authentication = managedObjectContext.safeFetch(authenticationRequest).first + + guard authentication == nil else { + // do nothing if still sign-in + return + } + + // cancel subscription if sign-out + let accessToken = mastodonPushNotification.accessToken + let mastodonAuthenticationBox = AuthenticationService.MastodonAuthenticationBox( + domain: domain, + userID: userID, + appAuthorization: .init(accessToken: accessToken), + userAuthorization: .init(accessToken: accessToken) + ) + apiService + .cancelSubscription(mastodonAuthenticationBox: mastodonAuthenticationBox) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] failed to cancel sign-out user subscription: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] cancel sign-out user subscription", ((#file as NSString).lastPathComponent), #line, #function) + } + } receiveValue: { _ in + // do nothing + } + .store(in: &self.disposeBag) + } } } diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 73a13bed1..6c49638da 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -76,7 +76,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - appContext.notificationService.handlePushNotification(notificationID: notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) completionHandler([.sound]) } @@ -95,7 +95,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let notificationID = String(mastodonPushNotification.notificationID) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Push Notification] notification %s", ((#file as NSString).lastPathComponent), #line, #function, notificationID) - appContext.notificationService.handlePushNotification(notificationID: notificationID) + appContext.notificationService.handle(mastodonPushNotification: mastodonPushNotification) appContext.notificationService.requestRevealNotificationPublisher.send(notificationID) completionHandler() } From 40b5472d1fb4f9e2c0f777bf8441efa3cfc0a7c1 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 27 Apr 2021 18:23:13 +0800 Subject: [PATCH 336/400] feat: add sound for push notification --- NotificationService/NotificationService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index f40f84ce6..08ba12be4 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -58,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension { bestAttemptContent.title = notification.title bestAttemptContent.subtitle = "" bestAttemptContent.body = notification.body + bestAttemptContent.sound = .default bestAttemptContent.userInfo["plaintext"] = plaintextData UserDefaults.shared.notificationBadgeCount += 1 From 193b69b6b10338ae367e16a781cd6dfa4683c75b Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 27 Apr 2021 19:41:55 +0800 Subject: [PATCH 337/400] fix: change accept to reject --- Mastodon/Diffiable/Section/NotificationSection.swift | 2 +- Mastodon/Scene/Notification/NotificationViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index d2df0a78b..ead5d48f8 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -100,7 +100,7 @@ extension NotificationSection { cell.rejectButton.publisher(for: .touchUpInside) .sink { [weak cell] _ in guard let cell = cell else { return } - cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.acceptButton) + cell.delegate?.notificationTableViewCell(cell, notification: notification, rejectButtonDidPressed: cell.rejectButton) } .store(in: &cell.disposeBag) cell.actionImageBackground.backgroundColor = color diff --git a/Mastodon/Scene/Notification/NotificationViewModel.swift b/Mastodon/Scene/Notification/NotificationViewModel.swift index f60e3d76d..f535c5598 100644 --- a/Mastodon/Scene/Notification/NotificationViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationViewModel.swift @@ -140,7 +140,7 @@ final class NotificationViewModel: NSObject { func rejectFollowRequest(notification: MastodonNotification) { guard let activeMastodonAuthenticationBox = self.activeMastodonAuthenticationBox.value else { return } - context.apiService.acceptFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) + context.apiService.rejectFollowRequest(mastodonUserID: notification.account.id, mastodonAuthenticationBox: activeMastodonAuthenticationBox) .sink { [weak self] completion in switch completion { case .failure(let error): From a9fdd2efa3f6beb47258906dde41c48037923dac Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Tue, 2 Mar 2021 13:27:53 +0800 Subject: [PATCH 338/400] fix: acct lookup support --- .../MastodonPickServerViewController.swift | 1 + .../Register/MastodonRegisterViewModel.swift | 35 +++++++++++++ .../MastodonServerRulesViewController.swift | 2 +- .../APIService/APIService+Account.swift | 13 +++++ .../API/Mastodon+API+Account.swift | 51 +++++++++++++++++++ 5 files changed, 101 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 685709719..638734c11 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -336,6 +336,7 @@ extension MastodonPickServerViewController { } else { let mastodonRegisterViewModel = MastodonRegisterViewModel( domain: server.domain, + context: self.context, authenticateInfo: response.authenticateInfo, instance: response.instance.value, applicationToken: response.applicationToken.value diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index cd6106c23..919443f2d 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -18,6 +18,7 @@ final class MastodonRegisterViewModel { let authenticateInfo: AuthenticationViewModel.AuthenticateInfo let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token + let context: AppContext let username = CurrentValueSubject("") let displayName = CurrentValueSubject("") @@ -46,11 +47,13 @@ final class MastodonRegisterViewModel { init( domain: String, + context: AppContext, authenticateInfo: AuthenticationViewModel.AuthenticateInfo, instance: Mastodon.Entity.Instance, applicationToken: Mastodon.Entity.Token ) { self.domain = domain + self.context = context self.authenticateInfo = authenticateInfo self.instance = instance self.applicationToken = applicationToken @@ -78,6 +81,21 @@ final class MastodonRegisterViewModel { } .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) + + username.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() + .sink { [weak self] text in + self?.lookupAccount(by: text) + } + .store(in: &disposeBag) + + usernameValidateState + .sink { [weak self] validateState in + if validateState == .valid { + self?.usernameErrorPrompt.value = nil + } + } + .store(in: &disposeBag) + displayName .map { displayname in guard !displayname.isEmpty else { return .empty } @@ -145,6 +163,23 @@ final class MastodonRegisterViewModel { .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } + + func lookupAccount(by acct: String) { + if acct.isEmpty { + return + } + let query = Mastodon.API.Account.AccountLookupQuery(acct: acct) + context.apiService.accountLookup(domain: domain, query: query, authorization: applicationAuthorization) + .sink { _ in + + } receiveValue: { [weak self] account in + guard let self = self else { return } + let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) + self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) + } + .store(in: &disposeBag) + + } } extension MastodonRegisterViewModel { diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index fb86e81e1..447896c22 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -204,7 +204,7 @@ extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain,context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/Mastodon/Service/APIService/APIService+Account.swift b/Mastodon/Service/APIService/APIService+Account.swift index 04908514b..7638f2444 100644 --- a/Mastodon/Service/APIService/APIService+Account.swift +++ b/Mastodon/Service/APIService/APIService+Account.swift @@ -152,4 +152,17 @@ extension APIService { ) } + func accountLookup( + domain: String, + query: Mastodon.API.Account.AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + return Mastodon.API.Account.lookupAccount( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 0f98dbe05..78f338b6f 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -132,3 +132,54 @@ extension Mastodon.API.Account { } } + +extension Mastodon.API.Account { + static func accountsLookupEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("accounts/lookup") + } + + public struct AccountLookupQuery: GetQuery { + + public var acct: String + + public init(acct: String) { + self.acct = acct + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + items.append(URLQueryItem(name: "acct", value: acct)) + return items + } + } + + /// lookup account by acct. + /// + /// - Version: 3.3.1 + + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `AccountInfoQuery` with account query information, + /// - authorization: user token + /// - Returns: `AnyPublisher` contains `Account` nested in the response + public static func lookupAccount( + session: URLSession, + domain: String, + query: AccountLookupQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: accountsLookupEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Account.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + +} From 7d8ffd187a79e33c29b4806a7bcdad56d215598e Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 15:02:34 +0800 Subject: [PATCH 339/400] feat: add media preview for status image --- Mastodon.xcodeproj/project.pbxproj | 94 ++++- .../xcschemes/xcschememanagement.plist | 4 +- Mastodon/Coordinator/SceneCoordinator.swift | 7 + ...Provider+StatusTableViewCellDelegate.swift | 10 +- .../StatusProvider/StatusProviderFacade.swift | 21 ++ .../HashtagTimelineViewController.swift | 4 +- ...elineViewController+DebugAction.swift.orig | 353 ++++++++++++++++++ .../HomeTimelineViewController.swift | 4 +- .../MediaPreviewViewController.swift | 134 +++++++ .../MediaPreview/MediaPreviewViewModel.swift | 92 +++++ .../Paging/Image/MediaPreviewImageView.swift | 214 +++++++++++ .../MediaPreviewImageViewController.swift | 115 ++++++ .../Image/MediaPreviewImageViewModel.swift | 41 ++ .../MediaPreviewPagingViewController.swift | 11 + .../Favorite/FavoriteViewController.swift | 4 +- .../Timeline/UserTimelineViewController.swift | 4 +- .../PublicTimelineViewController.swift | 4 +- .../Settings/SettingsViewModel.swift.orig | 215 +++++++++++ .../Scene/Thread/ThreadViewController.swift | 4 +- ...wViewControllerAnimatedTransitioning.swift | 299 +++++++++++++++ .../MediaPreviewTransitionController.swift | 126 +++++++ .../MediaPreviewTransitionItem.swift | 26 ++ .../MediaPreviewableViewController.swift | 12 + .../MediaPreviewingViewController.swift | 12 + .../ViewControllerAnimatedTransitioning.swift | 65 ++++ Mastodon/Vender/TransitioningMath.swift | 66 ++++ 26 files changed, 1928 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig create mode 100644 Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift create mode 100644 Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift create mode 100644 Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift create mode 100644 Mastodon/Scene/Settings/SettingsViewModel.swift.orig create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift create mode 100644 Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift create mode 100644 Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift create mode 100644 Mastodon/Vender/TransitioningMath.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index adbfa70ac..80ce2d0ea 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -254,6 +254,19 @@ DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F11725EFA35B001F1DAB /* StripProgressView.swift */; }; + DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */; }; + DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */; }; + DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */; }; + DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */; }; + DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */; }; + DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */; }; + DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EC26391C6C0018D199 /* TransitioningMath.swift */; }; + DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */; }; + DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */; }; + DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F326391D110018D199 /* MediaPreviewImageView.swift */; }; + DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */; }; + DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */; }; + DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */; }; DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */; }; DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729525F9F91600D60309 /* ComposeStatusSection.swift */; }; DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */; }; @@ -678,9 +691,9 @@ 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_MastodonUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 452147B2903DF38070FE56A2 /* Pods_MastodonTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MastodonTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 459EA4F43058CAB47719E963 /* Pods-Mastodon-MastodonUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.debug.xcconfig"; sourceTree = ""; }; + 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B24BBD7262DB14800A9381B /* ReportViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportViewModel.swift; sourceTree = ""; }; 5B24BBD8262DB14800A9381B /* ReportViewModel+Diffable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReportViewModel+Diffable.swift"; sourceTree = ""; }; - 46531DECCAB422F507B2274D /* Pods_Mastodon_AppShared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Mastodon_AppShared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B24BBE1262DB19100A9381B /* APIService+Report.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "APIService+Report.swift"; sourceTree = ""; }; 5B8E055726319E47006E3C53 /* ReportFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportFooterView.swift; sourceTree = ""; }; 5B90C456262599800002E742 /* SettingsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -802,6 +815,19 @@ DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; DB59F11725EFA35B001F1DAB /* StripProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripProgressView.swift; sourceTree = ""; }; + DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewController.swift; sourceTree = ""; }; + DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewPagingViewController.swift; sourceTree = ""; }; + DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionController.swift; sourceTree = ""; }; + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift; sourceTree = ""; }; + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionItem.swift; sourceTree = ""; }; + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitioningMath.swift; sourceTree = ""; }; + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewController.swift; sourceTree = ""; }; + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageViewModel.swift; sourceTree = ""; }; + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewImageView.swift; sourceTree = ""; }; + DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewableViewController.swift; sourceTree = ""; }; + DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewingViewController.swift; sourceTree = ""; }; + DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewViewModel.swift; sourceTree = ""; }; DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+Diffable.swift"; sourceTree = ""; }; DB66729525F9F91600D60309 /* ComposeStatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusSection.swift; sourceTree = ""; }; DB66729B25F9F91F00D60309 /* ComposeStatusItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusItem.swift; sourceTree = ""; }; @@ -1243,6 +1269,7 @@ DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */, DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, + DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, ); path = Vender; sourceTree = ""; @@ -1747,6 +1774,56 @@ path = View; sourceTree = ""; }; + DB6180DE263919350018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E1263919780018D199 /* Paging */, + DB6180DC263918E30018D199 /* MediaPreviewViewController.swift */, + DB6180F926391F2E0018D199 /* MediaPreviewViewModel.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180E1263919780018D199 /* Paging */ = { + isa = PBXGroup; + children = ( + DB6180F026391CAB0018D199 /* Image */, + DB6180DF2639194B0018D199 /* MediaPreviewPagingViewController.swift */, + ); + path = Paging; + sourceTree = ""; + }; + DB6180E426391A500018D199 /* Transition */ = { + isa = PBXGroup; + children = ( + DB6180E226391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift */, + DB6180E726391B580018D199 /* MediaPreview */, + ); + path = Transition; + sourceTree = ""; + }; + DB6180E726391B580018D199 /* MediaPreview */ = { + isa = PBXGroup; + children = ( + DB6180E526391B550018D199 /* MediaPreviewTransitionController.swift */, + DB6180E826391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift */, + DB6180EA26391C140018D199 /* MediaPreviewTransitionItem.swift */, + DB6180F526391D580018D199 /* MediaPreviewableViewController.swift */, + DB6180F726391D660018D199 /* MediaPreviewingViewController.swift */, + ); + path = MediaPreview; + sourceTree = ""; + }; + DB6180F026391CAB0018D199 /* Image */ = { + isa = PBXGroup; + children = ( + DB6180EE26391CA50018D199 /* MediaPreviewImageViewController.swift */, + DB6180F126391CF40018D199 /* MediaPreviewImageViewModel.swift */, + DB6180F326391D110018D199 /* MediaPreviewImageView.swift */, + ); + path = Image; + sourceTree = ""; + }; DB6804802637CD4C00430867 /* AppShared */ = { isa = PBXGroup; children = ( @@ -1944,6 +2021,7 @@ children = ( 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, + DB6180E426391A500018D199 /* Transition */, DB8AF54E25C13703002E6C99 /* MainTab */, DB01409B25C40BB600F9F3CF /* Onboarding */, 2D38F1D325CD463600561493 /* HomeTimeline */, @@ -1957,6 +2035,7 @@ DB9D6C0825E4F5A60051B173 /* Profile */, DB789A1025F9F29B0071ACA0 /* Compose */, DB938EEB2623F52600E5B6C1 /* Thread */, + DB6180DE263919350018D199 /* MediaPreview */, ); path = Scene; sourceTree = ""; @@ -2733,6 +2812,7 @@ files = ( DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */, + DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, DBE3CDCF261C42ED00430CC6 /* TimelineHeaderView.swift in Sources */, DBAE3F8E2616E0B1004B8251 /* APIService+Block.swift in Sources */, @@ -2761,8 +2841,10 @@ 5D0393962612D266007FE196 /* WebViewModel.swift in Sources */, 5B24BBDA262DB14800A9381B /* ReportViewModel.swift in Sources */, 2D5A3D3825CF8D9F002347D6 /* ScrollViewContainer.swift in Sources */, + DB6180EF26391CA50018D199 /* MediaPreviewImageViewController.swift in Sources */, DB1E347825F519300079D7DF /* PickServerItem.swift in Sources */, DB1FD45A25F27898004CFCFC /* CategoryPickerItem.swift in Sources */, + DB6180F626391D580018D199 /* MediaPreviewableViewController.swift in Sources */, 2D571B2F26004EC000540450 /* NavigationBarProgressView.swift in Sources */, 0FAA101225E105390017CCDE /* PrimaryActionButton.swift in Sources */, DB8AF53025C13561002E6C99 /* AppContext.swift in Sources */, @@ -2781,6 +2863,7 @@ DB87D4572609DD5300D12C0D /* DeleteBackwardResponseTextField.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, + DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, @@ -2819,8 +2902,10 @@ DBCBED1D26132E1A00B49291 /* StatusFetchedResultsController.swift in Sources */, 2D79E701261EA5550011E398 /* APIService+CoreData+Tag.swift in Sources */, 5B90C461262599800002E742 /* SettingsLinkTableViewCell.swift in Sources */, + DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */, DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */, DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */, + DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, 2D35238126256F690031AF25 /* NotificationTableViewCell.swift in Sources */, @@ -2828,6 +2913,7 @@ 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */, DB98338825C945ED00AD9700 /* Assets.swift in Sources */, + DB6180E926391BDF0018D199 /* MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift in Sources */, DB6B351E2601FAEE00DC1E11 /* ComposeStatusAttachmentCollectionViewCell.swift in Sources */, 2DA7D04425CA52B200804E11 /* TimelineLoaderTableViewCell.swift in Sources */, DB87D44B2609C11900D12C0D /* PollOptionView.swift in Sources */, @@ -2859,6 +2945,7 @@ 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, + DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, DB5086A525CC0B7000C2C187 /* AvatarBarButtonItem.swift in Sources */, @@ -2935,6 +3022,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */, DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */, 0FB3D30F25E525CD00AAD544 /* PickServerCategoryView.swift in Sources */, + DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */, 5BB04FDB262EA3070043BFF6 /* ReportHeaderView.swift in Sources */, DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */, DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */, @@ -2950,6 +3038,7 @@ 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */, DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB49A63D25FF609300B98345 /* PlayerContainerView+MediaTypeIndicotorView.swift in Sources */, + DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, @@ -3013,6 +3102,7 @@ DB8AF52E25C13561002E6C99 /* ViewStateStore.swift in Sources */, 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, + DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */, 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, @@ -3050,10 +3140,12 @@ 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, + DB6180E326391A4C0018D199 /* ViewControllerAnimatedTransitioning.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, DB6D9F4926353FD7008423CD /* Subscription.swift in Sources */, DB45FB0F25CA87D0005A8AC7 /* AuthenticationService.swift in Sources */, + DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 083bcfbbe..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 17 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 18 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index a71947e29..3768f7d3d 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -71,6 +71,9 @@ extension SceneCoordinator { // suggestion account case suggestionAccount(viewModel: SuggestionAccountViewModel) + // media preview + case mediaPreview(viewModel: MediaPreviewViewModel) + // misc case safari(url: URL) case alertController(alertController: UIAlertController) @@ -266,6 +269,10 @@ private extension SceneCoordinator { let _viewController = SuggestionAccountViewController() _viewController.viewModel = viewModel viewController = _viewController + case .mediaPreview(let viewModel): + let _viewController = MediaPreviewViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .safari(let url): guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index 198f0a4a3..bc0a8b2d9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -58,9 +58,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { // MARK: - MosciaImageViewContainerDelegate extension StatusTableViewCellDelegate where Self: StatusProvider { - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - - } + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) @@ -76,6 +74,12 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } +extension StatusTableViewCellDelegate where Self: StatusProvider & MediaPreviewableViewController { + func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) + } +} + // MARK: - PollTableView extension StatusTableViewCellDelegate where Self: StatusProvider { diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 03f84216a..8178ae95e 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -528,6 +528,27 @@ extension StatusProviderFacade { } } +extension StatusProviderFacade { + static func coordinateToStatusMediaPreviewScene(provider: StatusProvider & MediaPreviewableViewController, cell: UITableViewCell, mosaicImageView: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { + provider.status(for: cell, indexPath: nil) + .sink { [weak provider] status in + guard let provider = provider else { return } + guard let status = status?.reblog ?? status else { return } + + let meta = MediaPreviewViewModel.StatusImagePreviewMeta( + statusObjectID: status.objectID, + initialIndex: index, + preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } + ) + let mediaPreviewViewModel = MediaPreviewViewModel(context: provider.context, meta: meta) + DispatchQueue.main.async { + provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController)) + } + } + .store(in: &provider.disposeBag) + } +} + extension StatusProviderFacade { enum Target { case primary // original status diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index ea1a03aa9..7a3404732 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -12,7 +12,7 @@ import Combine import GameplayKit import CoreData -class HashtagTimelineViewController: UIViewController, NeedsDependency { +class HashtagTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -20,6 +20,8 @@ class HashtagTimelineViewController: UIViewController, NeedsDependency { var viewModel: HashtagTimelineViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let composeBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem() barButtonItem.tintColor = Asset.Colors.Label.highlight.color diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig new file mode 100644 index 000000000..a47aded6b --- /dev/null +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig @@ -0,0 +1,353 @@ +// +// HomeTimelineViewController+DebugAction.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-5. +// + +import os.log +import UIKit +import CoreData +import CoreDataStack + +#if DEBUG +extension HomeTimelineViewController { + var debugMenu: UIMenu { + let menu = UIMenu( + title: "Debug Tools", + image: nil, + identifier: nil, + options: .displayInline, + children: [ + moveMenu, + dropMenu, + UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showWelcomeAction(action) + }, + UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in + guard let self = self else { return } + if self.emptyView.superview != nil { + self.emptyView.removeFromSuperview() + } else { + self.showEmptyView() + } + }, + UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showPublicTimelineAction(action) + }, + UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showProfileAction(action) + }, + UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showThreadAction(action) + }, + UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in + guard let self = self else { return } + self.showSettings(action) + }, + UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in + guard let self = self else { return } + self.signOutAction(action) + } + ] + ) + return menu + } + + var moveMenu: UIMenu { + return UIMenu( + title: "Move to…", + image: UIImage(systemName: "arrow.forward.circle"), + identifier: nil, + options: [], + children: [ + UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToTopGapAction(action) + }), + UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstRepliedStatus(action) + }), + UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstReblogStatus(action) + }), + UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstPollStatus(action) + }), + UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstAudioStatus(action) + }), + UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstVideoStatus(action) + }), + UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.moveToFirstGIFStatus(action) + }), + ] + ) + } + + var dropMenu: UIMenu { + return UIMenu( + title: "Drop…", + image: UIImage(systemName: "minus.circle"), + identifier: nil, + options: [], + children: [50, 100, 150, 200, 250, 300].map { count in + UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in + guard let self = self else { return } + self.dropRecentStatusAction(action, count: count) + }) + } + ) + } +} + +extension HomeTimelineViewController { + + @objc private func moveToTopGapAction(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeMiddleLoader: return true + default: return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + } + } + + @objc private func moveToFirstReblogStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + return homeTimelineIndex.status.reblog != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found reblog status") + } + } + + @objc private func moveToFirstPollStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return post.poll != nil + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found poll status") + } + } + + @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + guard homeTimelineIndex.status.inReplyToID != nil else { + return false + } + return true + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found replied status") + } + } + + @objc private func moveToFirstAudioStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found audio status") + } + } + + @objc private func moveToFirstVideoStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found video status") + } + } + + @objc private func moveToFirstGIFStatus(_ sender: UIAction) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + let item = snapshotTransitioning.itemIdentifiers.first(where: { item in + switch item { + case .homeTimelineIndex(let objectID, _): + let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex + let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status + return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false + default: + return false + } + }) + if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { + tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) + tableView.blinkRow(at: IndexPath(row: index, section: 0)) + } else { + print("Not found GIF status") + } + } + + @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { + guard let diffableDataSource = viewModel.diffableDataSource else { return } + let snapshotTransitioning = diffableDataSource.snapshot() + + let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in + switch item { + case .homeTimelineIndex(let objectID, _): return objectID + default: return nil + } + } + var droppingStatusObjectIDs: [NSManagedObjectID] = [] + context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingObjectIDs { + guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } + droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) + self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) + } + } + .sink { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in + guard let self = self else { return } + for objectID in droppingStatusObjectIDs { + guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } + self.context.apiService.backgroundManagedObjectContext.delete(post) + } + } + .sink { _ in + // do nothing + } + .store(in: &self.disposeBag) + case .failure(let error): + assertionFailure(error.localizedDescription) + } + } + .store(in: &disposeBag) + } + + @objc private func showWelcomeAction(_ sender: UIAction) { + coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) + } + + @objc private func showPublicTimelineAction(_ sender: UIAction) { + coordinator.present(scene: .publicTimeline, from: self, transition: .show) + } + + @objc private func showProfileAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") + self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showThreadAction(_ sender: UIAction) { + let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) + alertController.addTextField() + let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in + guard let self = self else { return } + guard let textField = alertController?.textFields?.first else { return } + let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") + self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) + } + alertController.addAction(showAction) + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + alertController.addAction(cancelAction) + coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + + @objc private func showSettings(_ sender: UIAction) { +<<<<<<< HEAD + guard let currentSetting = context.settingService.currentSetting.value else { return } + let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) + coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) +======= + let viewModel = SettingsViewModel(context: context) + coordinator.present( + scene: .settings(viewModel: viewModel), + from: self, + transition: .modal(animated: true, completion: nil) + ) +>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d + } +} +#endif diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 3329ce8db..8932346ed 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -15,7 +15,7 @@ import GameplayKit import MastodonSDK import AlamofireImage -final class HomeTimelineViewController: UIViewController, NeedsDependency { +final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -23,6 +23,8 @@ final class HomeTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() private(set) lazy var viewModel = HomeTimelineViewModel(context: context) + let mediaPreviewTransitionController = MediaPreviewTransitionController() + lazy var emptyView: UIStackView = { let emptyView = UIStackView() emptyView.axis = .vertical diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift new file mode 100644 index 000000000..8845fff89 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -0,0 +1,134 @@ +// +// MediaPreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine +import Pageboy + +final class MediaPreviewViewController: UIViewController, NeedsDependency { + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: MediaPreviewViewModel! + + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) + let pagingViewConttroller = MediaPreviewPagingViewController() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension MediaPreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + overrideUserInterfaceStyle = .dark + + visualEffectView.frame = view.bounds + view.addSubview(visualEffectView) + + pagingViewConttroller.view.translatesAutoresizingMaskIntoConstraints = false + addChild(pagingViewConttroller) + visualEffectView.contentView.addSubview(pagingViewConttroller.view) + NSLayoutConstraint.activate([ + visualEffectView.topAnchor.constraint(equalTo: pagingViewConttroller.view.topAnchor), + visualEffectView.bottomAnchor.constraint(equalTo: pagingViewConttroller.view.bottomAnchor), + visualEffectView.leadingAnchor.constraint(equalTo: pagingViewConttroller.view.leadingAnchor), + visualEffectView.trailingAnchor.constraint(equalTo: pagingViewConttroller.view.trailingAnchor), + ]) + pagingViewConttroller.didMove(toParent: self) + + viewModel.mediaPreviewImageViewControllerDelegate = self + + pagingViewConttroller.interPageSpacing = 10 + pagingViewConttroller.delegate = self + pagingViewConttroller.dataSource = viewModel + } + +} + +// MARK: - MediaPreviewingViewController +extension MediaPreviewViewController: MediaPreviewingViewController { + + func isInteractiveDismissable() -> Bool { + return true +// if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController { +// let previewImageView = mediaPreviewImageViewController.previewImageView +// // TODO: allow zooming pan dismiss +// guard previewImageView.zoomScale == previewImageView.minimumZoomScale else { +// return false +// } +// +// let safeAreaInsets = previewImageView.safeAreaInsets +// let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 +// return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) +// } +// +// return false + } + +} + +// MARK: - PageboyViewControllerDelegate +extension MediaPreviewViewController: PageboyViewControllerDelegate { + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + willScrollToPageAt index: PageboyViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // do nothing + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollTo position: CGPoint, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // do nothing + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didScrollToPageAt index: PageboyViewController.PageIndex, + direction: PageboyViewController.NavigationDirection, + animated: Bool + ) { + // update page control + // pageControl.currentPage = index + } + + func pageboyViewController( + _ pageboyViewController: PageboyViewController, + didReloadWith currentViewController: UIViewController, + currentPageIndex: PageboyViewController.PageIndex + ) { + // do nothing + } + +} + + +// MARK: - MediaPreviewImageViewControllerDelegate +extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { + + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { + // delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer) + } + +} diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift new file mode 100644 index 000000000..744eab446 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -0,0 +1,92 @@ +// +// MediaPreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Combine +import CoreData +import CoreDataStack +import Pageboy + +final class MediaPreviewViewModel: NSObject { + + // input + let context: AppContext + let initialItem: PreviewItem + weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? + + // output + let viewControllers: [UIViewController] + + init(context: AppContext, meta: StatusImagePreviewMeta) { + self.context = context + self.initialItem = .status(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let status = managedObjectContext.object(with: meta.statusObjectID) as! Status + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return } + for (entity, image) in zip(media, meta.preloadThumbnailImages) { + let thumbnail: UIImage? = image.flatMap { $0.size != CGSize(width: 1, height: 1) ? $0 : nil } + switch entity.type { + case .image: + guard let url = URL(string: entity.url) else { continue } + let meta = MediaPreviewImageViewModel.StatusImagePreviewMeta(url: url, thumbnail: thumbnail) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + default: + continue + } + } + } + self.viewControllers = viewControllers + super.init() + } + +} + +extension MediaPreviewViewModel { + + enum PreviewItem { + case status(StatusImagePreviewMeta) + case local(LocalImagePreviewMeta) + } + + struct StatusImagePreviewMeta { + let statusObjectID: NSManagedObjectID + let initialIndex: Int + let preloadThumbnailImages: [UIImage?] + } + + struct LocalImagePreviewMeta { + let image: UIImage + } + +} + +// MARK: - PageboyViewControllerDataSource +extension MediaPreviewViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + let viewController = viewControllers[index] + if let mediaPreviewImageViewController = viewController as? MediaPreviewImageViewController { + mediaPreviewImageViewController.delegate = mediaPreviewImageViewControllerDelegate + } + return viewController + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + guard case let .status(meta) = initialItem else { return nil } + return .at(index: meta.initialIndex) + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift new file mode 100644 index 000000000..9d3b75b93 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -0,0 +1,214 @@ +// +// MediaPreviewImageView.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import func AVFoundation.AVMakeRect +import UIKit + +final class MediaPreviewImageView: UIScrollView { + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.isUserInteractionEnabled = true + return imageView + }() + + let doubleTapGestureRecognizer: UITapGestureRecognizer = { + let tapGestureRecognizer = UITapGestureRecognizer() + tapGestureRecognizer.numberOfTapsRequired = 2 + return tapGestureRecognizer + }() + + private var containerFrame: CGRect? + + override init(frame: CGRect) { + super.init(frame: frame) + _init() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + _init() + } + +} + +extension MediaPreviewImageView { + + private func _init() { + isUserInteractionEnabled = true + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + + bouncesZoom = true + minimumZoomScale = 1.0 + maximumZoomScale = 4.0 + + addSubview(imageView) + + doubleTapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageView.doubleTapGestureRecognizerHandler(_:))) + imageView.addGestureRecognizer(doubleTapGestureRecognizer) + + delegate = self + } + + override func layoutSubviews() { + super.layoutSubviews() + + guard let image = imageView.image else { return } + setup(image: image, container: self) + } + +} + +extension MediaPreviewImageView { + + @objc private func doubleTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let middleZoomScale = 0.5 * maximumZoomScale + if zoomScale >= middleZoomScale { + setZoomScale(minimumZoomScale, animated: true) + } else { + let center = sender.location(in: imageView) + let zoomRect: CGRect = { + let width = bounds.width / middleZoomScale + let height = bounds.height / middleZoomScale + return CGRect( + x: center.x - 0.5 * width, + y: center.y - 0.5 * height, + width: width, + height: height + ) + }() + zoom(to: zoomRect, animated: true) + } + } + +} + +extension MediaPreviewImageView { + + func setup(image: UIImage, container: UIView, forceUpdate: Bool = false) { + guard image.size.width > 0, image.size.height > 0 else { return } + guard container.bounds.width > 0, container.bounds.height > 0 else { return } + + // do not setup when frame not change except force update + if containerFrame == container.frame && !forceUpdate { + return + } + containerFrame = container.frame + + // reset to normal + zoomScale = minimumZoomScale + + let imageViewSize = AVMakeRect(aspectRatio: image.size, insideRect: container.bounds).size + let imageContentInset: UIEdgeInsets = { + if imageViewSize.width == container.bounds.width { + return UIEdgeInsets(top: 0.5 * (container.bounds.height - imageViewSize.height), left: 0, bottom: 0, right: 0) + } else { + return UIEdgeInsets(top: 0, left: 0.5 * (container.bounds.width - imageViewSize.width), bottom: 0, right: 0) + } + }() + imageView.frame = CGRect(origin: .zero, size: imageViewSize) + imageView.image = image + contentSize = imageViewSize + contentInset = imageContentInset + + centerScrollViewContents() + contentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top) + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setup image for container %s", ((#file as NSString).lastPathComponent), #line, #function, container.frame.debugDescription) + } + +} + +// MARK: - UIScrollViewDelegate +extension MediaPreviewImageView: UIScrollViewDelegate { + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + return false + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + centerScrollViewContents() + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return imageView + } + +} + +// Ref: https://stackoverflow.com/questions/14069571/keep-zoomable-image-in-center-of-uiscrollview +extension MediaPreviewImageView { + + private var scrollViewVisibleSize: CGSize { + let contentInset = self.contentInset + let scrollViewSize = bounds.standardized.size + let width = scrollViewSize.width - contentInset.left - contentInset.right + let height = scrollViewSize.height - contentInset.top - contentInset.bottom + return CGSize(width: width, height: height) + } + + private var scrollViewCenter: CGPoint { + let scrollViewSize = self.scrollViewVisibleSize + return CGPoint(x: scrollViewSize.width / 2.0, + y: scrollViewSize.height / 2.0) + } + + private func centerScrollViewContents() { + guard let image = imageView.image else { return } + + let imageViewSize = imageView.frame.size + let imageSize = image.size + + var realImageSize: CGSize + if imageSize.width / imageSize.height > imageViewSize.width / imageViewSize.height { + realImageSize = CGSize(width: imageViewSize.width, + height: imageViewSize.width / imageSize.width * imageSize.height) + } else { + realImageSize = CGSize(width: imageViewSize.height / imageSize.height * imageSize.width, + height: imageViewSize.height) + } + + var frame = CGRect.zero + frame.size = realImageSize + imageView.frame = frame + + let screenSize = self.frame.size + let offsetX = screenSize.width > realImageSize.width ? (screenSize.width - realImageSize.width) / 2 : 0 + let offsetY = screenSize.height > realImageSize.height ? (screenSize.height - realImageSize.height) / 2 : 0 + contentInset = UIEdgeInsets(top: offsetY, left: offsetX, bottom: offsetY, right: offsetX) + + // The scroll view has zoomed, so you need to re-center the contents + let scrollViewSize = scrollViewVisibleSize + + // First assume that image center coincides with the contents box center. + // This is correct when the image is bigger than scrollView due to zoom + var imageCenter = CGPoint(x: contentSize.width / 2.0, + y: contentSize.height / 2.0) + + let center = scrollViewCenter + + //if image is smaller than the scrollView visible size - fix the image center accordingly + if contentSize.width < scrollViewSize.width { + imageCenter.x = center.x + } + + if contentSize.height < scrollViewSize.height { + imageCenter.y = center.y + } + + imageView.center = imageCenter + } + +} + diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift new file mode 100644 index 000000000..f44e1de8f --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -0,0 +1,115 @@ +// +// MediaPreviewImageViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit +import Combine + +protocol MediaPreviewImageViewControllerDelegate: class { + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) +} + +final class MediaPreviewImageViewController: UIViewController { + + var disposeBag = Set() + var viewModel: MediaPreviewImageViewModel! + weak var delegate: MediaPreviewImageViewControllerDelegate? + + // let progressBarView = ProgressBarView() + let previewImageView = MediaPreviewImageView() + + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + let longPressGestureRecognizer = UILongPressGestureRecognizer() + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + previewImageView.imageView.af.cancelImageRequest() + } +} + +extension MediaPreviewImageViewController { + + override func viewDidLoad() { + super.viewDidLoad() + +// progressBarView.tintColor = .white +// progressBarView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(progressBarView) +// NSLayoutConstraint.activate([ +// progressBarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), +// progressBarView.centerYAnchor.constraint(equalTo: view.centerYAnchor), +// progressBarView.widthAnchor.constraint(equalToConstant: 120), +// progressBarView.heightAnchor.constraint(equalToConstant: 44), +// ]) + + previewImageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(previewImageView) + NSLayoutConstraint.activate([ + previewImageView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + previewImageView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:))) + longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:))) + tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer) + tapGestureRecognizer.require(toFail: longPressGestureRecognizer) + previewImageView.addGestureRecognizer(tapGestureRecognizer) + previewImageView.addGestureRecognizer(longPressGestureRecognizer) + + switch viewModel.item { + case .status(let meta): +// progressBarView.isHidden = meta.thumbnail != nil + previewImageView.imageView.af.setImage( + withURL: meta.url, + placeholderImage: meta.thumbnail, + filter: nil, + progress: { [weak self] progress in + guard let self = self else { return } + // self.progressBarView.progress.value = CGFloat(progress.fractionCompleted) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %s progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription, progress.fractionCompleted) + }, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .success(let image): + //self.progressBarView.isHidden = true + self.previewImageView.imageView.image = image + self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) + case .failure(let error): + // TODO: + break + } + } + ) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setImage url: %s", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription) + case .local(let meta): + // progressBarView.isHidden = true + previewImageView.imageView.image = meta.image + self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true) + } + } + +} + +extension MediaPreviewImageViewController { + + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.mediaPreviewImageViewController(self, tapGestureRecognizerDidTrigger: sender) + } + + @objc private func longPressGestureRecognizerHandler(_ sender: UILongPressGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.mediaPreviewImageViewController(self, longPressGestureRecognizerDidTrigger: sender) + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift new file mode 100644 index 000000000..d59cb5778 --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -0,0 +1,41 @@ +// +// MediaPreviewImageViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Combine + +class MediaPreviewImageViewModel { + + // input + let item: ImagePreviewItem + + init(meta: StatusImagePreviewMeta) { + self.item = .status(meta) + } + + init(meta: LocalImagePreviewMeta) { + self.item = .local(meta) + } + +} + +extension MediaPreviewImageViewModel { + enum ImagePreviewItem { + case status(StatusImagePreviewMeta) + case local(LocalImagePreviewMeta) + } + + struct StatusImagePreviewMeta { + let url: URL + let thumbnail: UIImage? + } + + struct LocalImagePreviewMeta { + let image: UIImage + } + +} diff --git a/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift new file mode 100644 index 000000000..b3a3eb41f --- /dev/null +++ b/Mastodon/Scene/MediaPreview/Paging/MediaPreviewPagingViewController.swift @@ -0,0 +1,11 @@ +// +// MediaPreviewPagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit +import Pageboy + +final class MediaPreviewPagingViewController: PageboyViewController { } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 1e10a6322..83678cd56 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -14,7 +14,7 @@ import AVKit import Combine import GameplayKit -final class FavoriteViewController: UIViewController, NeedsDependency { +final class FavoriteViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -22,6 +22,8 @@ final class FavoriteViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: FavoriteViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let titleView = DoubleTitleLabelNavigationBarTitleView() lazy var tableView: UITableView = { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 2ec350b07..d44dd7447 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -13,7 +13,7 @@ import CoreDataStack import GameplayKit // TODO: adopt MediaPreviewableViewController -final class UserTimelineViewController: UIViewController, NeedsDependency { +final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -21,7 +21,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: UserTimelineViewModel! - // let mediaPreviewTransitionController = MediaPreviewTransitionController() + let mediaPreviewTransitionController = MediaPreviewTransitionController() lazy var tableView: UITableView = { let tableView = UITableView() diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift index 844c43cd8..781d2ce1b 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController.swift @@ -13,13 +13,15 @@ import GameplayKit import os.log import UIKit -final class PublicTimelineViewController: UIViewController, NeedsDependency { +final class PublicTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var viewModel: PublicTimelineViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let refreshControl = UIRefreshControl() lazy var tableView: UITableView = { diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig new file mode 100644 index 000000000..c5ae31a89 --- /dev/null +++ b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig @@ -0,0 +1,215 @@ +// +// SettingsViewModel.swift +// Mastodon +// +// Created by ihugo on 2021/4/7. +// + +import Combine +import CoreData +import CoreDataStack +import Foundation +import MastodonSDK +import UIKit +import os.log + +<<<<<<< HEAD +class SettingsViewModel { +======= +class SettingsViewModel: NSObject { + // confirm set only once + weak var context: AppContext! { willSet { precondition(context == nil) } } +>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d + + var disposeBag = Set() + + let context: AppContext + + // input + let setting: CurrentValueSubject + var updateDisposeBag = Set() + var createDisposeBag = Set() + + let viewDidLoad = PassthroughSubject() + + // output + var dataSource: UITableViewDiffableDataSource! + /// create a subscription when: + /// - does not has one + /// - does not find subscription for selected trigger when change trigger + let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + /// update a subscription when: + /// - change switch for specified alerts + let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() + + lazy var privacyURL: URL? = { + guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { + return nil + } + + return Mastodon.API.privacyURL(domain: box.domain) + }() + +<<<<<<< HEAD + init(context: AppContext, setting: Setting) { + self.context = context + self.setting = CurrentValueSubject(setting) +======= + /// to store who trigger the notification. + var triggerBy: String? + + struct Input { + } + + struct Output { + } + + init(context: AppContext) { + self.context = context +>>>>>>> 2e8183adc646f2871b530b642717e3aab782721d + + self.setting + .sink(receiveValue: { [weak self] setting in + guard let self = self else { return } + self.processDataSource(setting) + }) + .store(in: &disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension SettingsViewModel { + + // MARK: - Private methods + private func processDataSource(_ setting: Setting) { + guard let dataSource = self.dataSource else { return } + var snapshot = NSDiffableDataSourceSnapshot() + + // appearance + let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] + snapshot.appendSections([.apperance]) + snapshot.appendItems(appearanceItems, toSection: .apperance) + + let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in + SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) + } + snapshot.appendSections([.notifications]) + snapshot.appendItems(notificationItems, toSection: .notifications) + + // boring zone + let boringZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .termsOfService, + .privacyPolicy + ] + let items = links.map { SettingsItem.boringZone(item: $0) } + return items + }() + snapshot.appendSections([.boringZone]) + snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) + + let spicyZoneSettingsItems: [SettingsItem] = { + let links: [SettingsItem.Link] = [ + .clearMediaCache, + .signOut + ] + let items = links.map { SettingsItem.spicyZone(item: $0) } + return items + }() + snapshot.appendSections([.spicyZone]) + snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) + + dataSource.apply(snapshot, animatingDifferences: false) + } + +} + +extension SettingsViewModel { + func setupDiffableDataSource( + for tableView: UITableView, + settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, + settingsToggleCellDelegate: SettingsToggleCellDelegate + ) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ + weak self, + weak settingsAppearanceTableViewCellDelegate, + weak settingsToggleCellDelegate + ] tableView, indexPath, item -> UITableViewCell? in + guard let self = self else { return nil } + + switch item { + case .apperance(let objectID): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + cell.update(with: setting.appearance) + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + cell.update(with: setting.appearance) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsAppearanceTableViewCellDelegate + return cell + case .notification(let objectID, let switchMode): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell + self.context.managedObjectContext.performAndWait { + let setting = self.context.managedObjectContext.object(with: objectID) as! Setting + if let subscription = setting.activeSubscription { + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + } + ManagedObjectObserver.observe(object: setting) + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard case .update(let object) = change.changeType, + let setting = object as? Setting else { return } + guard let subscription = setting.activeSubscription else { return } + SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) + }) + .store(in: &cell.disposeBag) + } + cell.delegate = settingsToggleCellDelegate + return cell + case .boringZone(let item), .spicyZone(let item): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell + cell.update(with: item) + return cell + } + } + + processDataSource(self.setting.value) + } +} + +extension SettingsViewModel { + + static func configureSettingToggle( + cell: SettingsToggleTableViewCell, + switchMode: SettingsItem.NotificationSwitchMode, + subscription: NotificationSubscription + ) { + cell.textLabel?.text = switchMode.title + + let enabled: Bool? + switch switchMode { + case .favorite: enabled = subscription.alert.favourite + case .follow: enabled = subscription.alert.follow + case .reblog: enabled = subscription.alert.reblog + case .mention: enabled = subscription.alert.mention + } + cell.update(enabled: enabled) + } + +} diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index bd15b930e..8ca8a3395 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -11,7 +11,7 @@ import Combine import CoreData import AVKit -final class ThreadViewController: UIViewController, NeedsDependency { +final class ThreadViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -19,6 +19,8 @@ final class ThreadViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ThreadViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + let titleView = DoubleTitleLabelNavigationBarTitleView() let replyBarButtonItem = AdaptiveUserInterfaceStyleBarButtonItem( diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift new file mode 100644 index 000000000..21142ad96 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -0,0 +1,299 @@ +// +// MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit + +final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning { + + + let transitionItem: MediaPreviewTransitionItem + let panGestureRecognizer: UIPanGestureRecognizer + + private var isTransitionContextFinish = false + + private var popInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) + private var itemInteractiveTransitionAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) + + init(operation: UINavigationController.Operation, transitionItem: MediaPreviewTransitionItem, panGestureRecognizer: UIPanGestureRecognizer) { + self.transitionItem = transitionItem + self.panGestureRecognizer = panGestureRecognizer + super.init(operation: operation) + } + + class func animator(initialVelocity: CGVector = .zero) -> UIViewPropertyAnimator { + let timingParameters = UISpringTimingParameters(mass: 4.0, stiffness: 1300, damping: 180, initialVelocity: initialVelocity) + return UIViewPropertyAnimator(duration: 0.5, timingParameters: timingParameters) + } + +} + + +// MARK: - UIViewControllerAnimatedTransitioning +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + override func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + super.animateTransition(using: transitionContext) + + switch operation { + case .push: pushTransition(using: transitionContext).startAnimation() + case .pop: popTransition(using: transitionContext).startAnimation() + default: return + } + } + + private func pushTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { + guard let toVC = transitionContext.viewController(forKey: .to) as? MediaPreviewViewController, + let toView = transitionContext.view(forKey: .to) else { + fatalError() + } + + let toViewEndFrame = transitionContext.finalFrame(for: toVC) + toView.frame = toViewEndFrame + toView.alpha = 0 + transitionContext.containerView.addSubview(toView) + + let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve) + + animator.addAnimations { + toView.alpha = 1 + } + + animator.addCompletion { position in + transitionContext.completeTransition(position == .end) + } + + return animator + } + + private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { + guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let fromView = transitionContext.view(forKey: .from) else { + fatalError() + } + + let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve) + + animator.addAnimations { + fromView.alpha = 0 + } + + animator.addCompletion { position in + transitionContext.completeTransition(position == .end) + } + + return animator + } + +} + +// MARK: - UIViewControllerInteractiveTransitioning +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + override func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + super.startInteractiveTransition(transitionContext) + + switch operation { + case .pop: + guard let mediaPreviewViewController = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let mediaPreviewImageViewController = mediaPreviewViewController.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController else { + transitionContext.completeTransition(false) + return + } + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { +// if imageView.image == nil { +// transitionItem.snapshotRaw = mediaPreviewImageViewController.progressBarView +// return mediaPreviewImageViewController.progressBarView.snapshotView(afterScreenUpdates: false) +// } else { + transitionItem.snapshotRaw = imageView + return imageView.snapshotView(afterScreenUpdates: false) +// } + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + return + } + mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView) + + snapshot.center = transitionContext.containerView.center + + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = snapshot.frame + + panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:))) + popInteractiveTransition(using: transitionContext) + default: + assertionFailure() + return + } + } + + private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, + let fromView = transitionContext.view(forKey: .from) else { + fatalError() + } + + let animator = popInteractiveTransitionAnimator + + let blurEffect = fromVC.visualEffectView.effect + self.transitionItem.imageView?.isHidden = true + self.transitionItem.snapshotRaw?.alpha = 0.0 + animator.addAnimations { + self.transitionItem.snapshotTransitioning?.alpha = 0.4 +// fromVC.mediaInfoDescriptionView.alpha = 0 +// fromVC.closeButtonBackground.alpha = 0 +// fromVC.pageControl.alpha = 0 + fromVC.visualEffectView.effect = nil + } + + animator.addCompletion { position in + self.transitionItem.imageView?.isHidden = position == .end + self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + fromVC.visualEffectView.effect = position == .end ? nil : blurEffect + transitionContext.completeTransition(position == .end) + } + } + +} + +extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { + + @objc func updatePanGestureInteractive(_ sender: UIPanGestureRecognizer) { + guard !isTransitionContextFinish else { return } // do not accept transition abort + + switch sender.state { + case .began, .changed: + let translation = sender.translation(in: transitionContext.containerView) + let percent = popInteractiveTransitionAnimator.fractionComplete + progressStep(for: translation) + popInteractiveTransitionAnimator.fractionComplete = percent + transitionContext.updateInteractiveTransition(percent) + updateTransitionItemPosition(of: translation) + + // Reset translation to zero + sender.setTranslation(CGPoint.zero, in: transitionContext.containerView) + case .ended, .cancelled: + let targetPosition = completionPosition() + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: target position: %s", ((#file as NSString).lastPathComponent), #line, #function, targetPosition == .end ? "end" : "start") + targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() + isTransitionContextFinish = true + animate(targetPosition) + + default: + return + } + } + + private func convert(_ velocity: CGPoint, for item: MediaPreviewTransitionItem?) -> CGVector { + guard let currentFrame = item?.imageView?.frame, let targetFrame = item?.targetFrame else { + return CGVector.zero + } + + let dx = abs(targetFrame.midX - currentFrame.midX) + let dy = abs(targetFrame.midY - currentFrame.midY) + + guard dx > 0.0 && dy > 0.0 else { + return CGVector.zero + } + + let range = CGFloat(35.0) + let clippedVx = clip(-range, range, velocity.x / dx) + let clippedVy = clip(-range, range, velocity.y / dy) + return CGVector(dx: clippedVx, dy: clippedVy) + } + + private func completionPosition() -> UIViewAnimatingPosition { + let completionThreshold: CGFloat = 0.33 + let flickMagnitude: CGFloat = 1200 // pts/sec + let velocity = panGestureRecognizer.velocity(in: transitionContext.containerView).vector + let isFlick = (velocity.magnitude > flickMagnitude) + let isFlickDown = isFlick && (velocity.dy > 0.0) + let isFlickUp = isFlick && (velocity.dy < 0.0) + + if (operation == .push && isFlickUp) || (operation == .pop && isFlickDown) { + return .end + } else if (operation == .push && isFlickDown) || (operation == .pop && isFlickUp) { + return .start + } else if popInteractiveTransitionAnimator.fractionComplete > completionThreshold { + return .end + } else { + return .start + } + } + + // Create item animator and start it + func animate(_ toPosition: UIViewAnimatingPosition) { + // Create a property animator to animate each image's frame change + let gestureVelocity = panGestureRecognizer.velocity(in: transitionContext.containerView) + let velocity = convert(gestureVelocity, for: transitionItem) + let itemAnimator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: velocity) + + itemAnimator.addAnimations { + if toPosition == .end { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } else { + self.transitionItem.snapshotTransitioning?.alpha = 1 + self.transitionItem.snapshotTransitioning?.frame = self.transitionItem.initialFrame! + } + } + + // Start the property animator and keep track of it + self.itemInteractiveTransitionAnimator = itemAnimator + itemAnimator.startAnimation() + + // Reverse the transition animator if we are returning to the start position + popInteractiveTransitionAnimator.isReversed = (toPosition == .start) + + if popInteractiveTransitionAnimator.state == .inactive { + popInteractiveTransitionAnimator.startAnimation() + } else { + let durationFactor = CGFloat(itemAnimator.duration / popInteractiveTransitionAnimator.duration) + popInteractiveTransitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor) + } + } + + private func progressStep(for translation: CGPoint) -> CGFloat { + return (operation == .push ? -1.0 : 1.0) * translation.y / transitionContext.containerView.bounds.midY + } + + private func updateTransitionItemPosition(of translation: CGPoint) { + let progress = progressStep(for: translation) + + let initialSize = transitionItem.initialFrame!.size + assert(initialSize != .zero) + + guard let snapshot = transitionItem.snapshotTransitioning, + let finalSize = transitionItem.targetFrame?.size else { + return + } + + if snapshot.frame.size == .zero { + snapshot.frame.size = initialSize + } + + let currentSize = snapshot.frame.size + + let itemPercentComplete = clip(-0.05, 1.05, (currentSize.width - initialSize.width) / (finalSize.width - initialSize.width) + progress) + let itemWidth = lerp(initialSize.width, finalSize.width, itemPercentComplete) + let itemHeight = lerp(initialSize.height, finalSize.height, itemPercentComplete) + assert(currentSize.width != 0.0) + assert(currentSize.height != 0.0) + let scaleTransform = CGAffineTransform(scaleX: (itemWidth / currentSize.width), y: (itemHeight / currentSize.height)) + let scaledOffset = transitionItem.touchOffset.apply(transform: scaleTransform) + + snapshot.center = (snapshot.center + (translation + (transitionItem.touchOffset - scaledOffset))).point + snapshot.bounds = CGRect(origin: CGPoint.zero, size: CGSize(width: itemWidth, height: itemHeight)) + transitionItem.touchOffset = scaledOffset + } + +} + diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift new file mode 100644 index 000000000..83b008eef --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -0,0 +1,126 @@ +// +// MediaPreviewTransitionController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit + +final class MediaPreviewTransitionController: NSObject { + + weak var mediaPreviewViewController: MediaPreviewViewController? + + var wantsInteractiveStart = false + private var panGestureRecognizer: UIPanGestureRecognizer = { + let gestureRecognizer = UIPanGestureRecognizer() + gestureRecognizer.maximumNumberOfTouches = 1 + return gestureRecognizer + }() + private var dismissInteractiveTransitioning: MediaHostToMediaPreviewViewControllerAnimatedTransitioning? + + override init() { + super.init() + + panGestureRecognizer.delegate = self + panGestureRecognizer.addTarget(self, action: #selector(MediaPreviewTransitionController.panGestureRecognizerHandler(_:))) + } + +} + +extension MediaPreviewTransitionController { + + @objc private func panGestureRecognizerHandler(_ sender: UIPanGestureRecognizer) { + guard dismissInteractiveTransitioning == nil else { return } + + guard let mediaPreviewViewController = self.mediaPreviewViewController else { return } + wantsInteractiveStart = true + mediaPreviewViewController.dismiss(animated: true, completion: nil) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: start interactive dismiss", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - UIGestureRecognizerDelegate +extension MediaPreviewTransitionController: UIGestureRecognizerDelegate { + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === panGestureRecognizer { + // FIXME: should enable zoom up pan dismiss + return false + } + return true + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === panGestureRecognizer { + guard let mediaPreviewViewController = self.mediaPreviewViewController else { return false } + return mediaPreviewViewController.isInteractiveDismissable() + } + + return false + } +} + +// MARK: - UIViewControllerTransitioningDelegate +extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegate { + + func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard let mediaPreviewViewController = presented as? MediaPreviewViewController else { + assertionFailure() + return nil + } + self.mediaPreviewViewController = mediaPreviewViewController + self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer) + + let transitionItem = MediaPreviewTransitionItem(id: UUID()) + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .push, + transitionItem: transitionItem, + panGestureRecognizer: panGestureRecognizer + ) + } + + func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + // not support interactive present + return nil + } + + func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard let mediaPreviewViewController = dismissed as? MediaPreviewViewController else { + assertionFailure() + return nil + } + let transitionItem = MediaPreviewTransitionItem(id: UUID()) + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( + operation: .pop, + transitionItem: transitionItem, + panGestureRecognizer: panGestureRecognizer + ) + } + + func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + guard let transitioning = animator as? MediaHostToMediaPreviewViewControllerAnimatedTransitioning, + transitioning.operation == .pop, wantsInteractiveStart else { + return nil + } + + dismissInteractiveTransitioning = transitioning + transitioning.delegate = self + return transitioning + } + +} + +// MARK: - ViewControllerAnimatedTransitioningDelegate +extension MediaPreviewTransitionController: ViewControllerAnimatedTransitioningDelegate { + + func animationEnded(_ transitionCompleted: Bool) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: completed: %s", ((#file as NSString).lastPathComponent), #line, #function, transitionCompleted.description) + + dismissInteractiveTransitioning = nil + wantsInteractiveStart = false + } + +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift new file mode 100644 index 000000000..de9f0fbfb --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -0,0 +1,26 @@ +// +// MediaPreviewTransitionItem.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import UIKit + +class MediaPreviewTransitionItem: Identifiable { + + let id: UUID + + // TODO: + var imageView: UIImageView? + var snapshotRaw: UIView? + var snapshotTransitioning: UIView? + var initialFrame: CGRect? = nil + var targetFrame: CGRect? = nil + var touchOffset: CGVector = CGVector.zero + + init(id: UUID) { + self.id = id + } + +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift new file mode 100644 index 000000000..7e4c39d06 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -0,0 +1,12 @@ +// +// MediaPreviewableViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import Foundation + +protocol MediaPreviewableViewController: class { + var mediaPreviewTransitionController: MediaPreviewTransitionController { get } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift new file mode 100644 index 000000000..1b92e5127 --- /dev/null +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift @@ -0,0 +1,12 @@ +// +// MediaPreviewingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import Foundation + +protocol MediaPreviewingViewController: class { + func isInteractiveDismissable() -> Bool +} diff --git a/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift new file mode 100644 index 000000000..078bf6565 --- /dev/null +++ b/Mastodon/Scene/Transition/ViewControllerAnimatedTransitioning.swift @@ -0,0 +1,65 @@ +// +// ViewControllerAnimatedTransitioning.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-28. +// + +import os.log +import UIKit + +protocol ViewControllerAnimatedTransitioningDelegate: AnyObject { + var wantsInteractiveStart: Bool { get } + func animationEnded(_ transitionCompleted: Bool) +} + +class ViewControllerAnimatedTransitioning: NSObject { + + let operation: UINavigationController.Operation + + var transitionContext: UIViewControllerContextTransitioning! + var isInteractive: Bool { return transitionContext.isInteractive } + + weak var delegate: ViewControllerAnimatedTransitioningDelegate? + + init(operation: UINavigationController.Operation) { + assert(operation != .none) + self.operation = operation + super.init() + } + + deinit { + os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +// MARK: - UIViewControllerAnimatedTransitioning +extension ViewControllerAnimatedTransitioning: UIViewControllerAnimatedTransitioning { + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return 0.3 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + self.transitionContext = transitionContext + } + + func animationEnded(_ transitionCompleted: Bool) { + delegate?.animationEnded(transitionCompleted) + } + +} + +// MARK: - UIViewControllerInteractiveTransitioning +extension ViewControllerAnimatedTransitioning: UIViewControllerInteractiveTransitioning { + + func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) { + self.transitionContext = transitionContext + } + + var wantsInteractiveStart: Bool { + return delegate?.wantsInteractiveStart ?? false + } + +} diff --git a/Mastodon/Vender/TransitioningMath.swift b/Mastodon/Vender/TransitioningMath.swift new file mode 100644 index 000000000..6639b4dd8 --- /dev/null +++ b/Mastodon/Vender/TransitioningMath.swift @@ -0,0 +1,66 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt for this sample’s licensing information + + Abstract: + Convenience math operators + */ + +import QuartzCore + +func clip(_ x0: T, _ x1: T, _ v: T) -> T { + return max(x0, min(x1, v)) +} + +func lerp(_ v0: T, _ v1: T, _ t: T) -> T { + return v0 + (v1 - v0) * t +} + + +func -(lhs: CGPoint, rhs: CGPoint) -> CGVector { + return CGVector(dx: lhs.x - rhs.x, dy: lhs.y - rhs.y) +} + +func -(lhs: CGPoint, rhs: CGVector) -> CGPoint { + return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy) +} + +func -(lhs: CGVector, rhs: CGVector) -> CGVector { + return CGVector(dx: lhs.dx - rhs.dx, dy: lhs.dy - rhs.dy) +} + +func +(lhs: CGPoint, rhs: CGPoint) -> CGVector { + return CGVector(dx: lhs.x + rhs.x, dy: lhs.y + rhs.y) +} + +func +(lhs: CGPoint, rhs: CGVector) -> CGPoint { + return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy) +} + +func +(lhs: CGVector, rhs: CGVector) -> CGVector { + return CGVector(dx: lhs.dx + rhs.dx, dy: lhs.dy + rhs.dy) +} + +func *(left: CGVector, right:CGFloat) -> CGVector { + return CGVector(dx: left.dx * right, dy: left.dy * right) +} + +extension CGPoint { + var vector: CGVector { + return CGVector(dx: x, dy: y) + } +} + +extension CGVector { + var magnitude: CGFloat { + return sqrt(dx*dx + dy*dy) + } + + var point: CGPoint { + return CGPoint(x: dx, y: dy) + } + + func apply(transform t: CGAffineTransform) -> CGVector { + return point.applying(t).vector + } +} From 8024be84b6454e603151435e76dc259df8fe1a29 Mon Sep 17 00:00:00 2001 From: ihugo Date: Wed, 28 Apr 2021 17:02:19 +0800 Subject: [PATCH 340/400] fix: Poll option placeholder not update after reorder fix #109 --- Mastodon/Scene/Compose/ComposeViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index a18cf9216..ca99d2e88 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -566,6 +566,7 @@ extension ComposeViewController { collectionView.updateInteractiveMovementTargetPosition(position) case .ended: collectionView.endInteractiveMovement() + collectionView.reloadData() default: collectionView.cancelInteractiveMovement() } From 6f0b4354a7c87c8b4ef618ae76498723c9eb2160 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 19:06:45 +0800 Subject: [PATCH 341/400] feat: update preview present/dismiss transition style to pin-to-source rect --- .../StatusProvider/StatusProviderFacade.swift | 41 +++- .../MediaPreviewViewController.swift | 93 +++++++-- .../MediaPreview/MediaPreviewViewModel.swift | 8 +- .../Paging/Image/MediaPreviewImageView.swift | 5 +- .../Container/MosaicImageViewContainer.swift | 19 ++ .../ViewModel/MosaicImageViewModel.swift | 44 +++-- ...wViewControllerAnimatedTransitioning.swift | 178 +++++++++++++----- .../MediaPreviewTransitionController.swift | 7 +- .../MediaPreviewTransitionItem.swift | 27 ++- .../MediaPreviewableViewController.swift | 16 +- .../MediaPreviewingViewController.swift | 2 +- 11 files changed, 344 insertions(+), 96 deletions(-) diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index 8178ae95e..d53e8e038 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -533,14 +533,51 @@ extension StatusProviderFacade { provider.status(for: cell, indexPath: nil) .sink { [weak provider] status in guard let provider = provider else { return } - guard let status = status?.reblog ?? status else { return } + guard let source = status else { return } + + let status = source.reblog ?? source let meta = MediaPreviewViewModel.StatusImagePreviewMeta( statusObjectID: status.objectID, initialIndex: index, preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } ) - let mediaPreviewViewModel = MediaPreviewViewModel(context: provider.context, meta: meta) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .mosaic(mosaicImageView), + previewableViewController: provider + ) + pushTransitionItem.aspectRatio = { + if let image = imageView.image { + return image.size + } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + let meta = media[index].meta + guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } + return CGSize(width: width, height: height) + }() + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = { + if let image = imageView.image { + return image + } + if index < mosaicImageView.blurhashOverlayImageViews.count { + return mosaicImageView.blurhashOverlayImageViews[index].image + } + + return nil + }() + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: provider.context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) DispatchQueue.main.async { provider.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: provider, transition: .custom(transitioningDelegate: provider.mediaPreviewTransitionController)) } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 8845fff89..9a5157990 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -12,6 +12,8 @@ import Pageboy final class MediaPreviewViewController: UIViewController, NeedsDependency { + static let closeButtonSize = CGSize(width: 30, height: 30) + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -20,6 +22,23 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewConttroller = MediaPreviewPagingViewController() + + let closeButtonBackground: UIVisualEffectView = { + let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial)) + backgroundView.alpha = 0.9 + backgroundView.layer.masksToBounds = true + backgroundView.layer.cornerRadius = MediaPreviewViewController.closeButtonSize.width * 0.5 + return backgroundView + }() + + let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) + + let closeButton: UIButton = { + let button = HitTestExpandedButton() + button.imageView?.tintColor = .label + button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) + return button + }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -48,11 +67,57 @@ extension MediaPreviewViewController { ]) pagingViewConttroller.didMove(toParent: self) + closeButtonBackground.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(closeButtonBackground) + NSLayoutConstraint.activate([ + closeButtonBackground.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor, constant: 12), + closeButtonBackground.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor) + ]) + closeButtonBackgroundVisualEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + closeButtonBackground.contentView.addSubview(closeButtonBackgroundVisualEffectView) + + closeButton.translatesAutoresizingMaskIntoConstraints = false + closeButtonBackgroundVisualEffectView.contentView.addSubview(closeButton) + NSLayoutConstraint.activate([ + closeButton.topAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.topAnchor), + closeButton.leadingAnchor.constraint(equalTo: closeButtonBackgroundVisualEffectView.leadingAnchor), + closeButtonBackgroundVisualEffectView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor), + closeButtonBackgroundVisualEffectView.bottomAnchor.constraint(equalTo: closeButton.bottomAnchor), + closeButton.heightAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.height).priority(.defaultHigh), + closeButton.widthAnchor.constraint(equalToConstant: MediaPreviewViewController.closeButtonSize.width).priority(.defaultHigh), + ]) + viewModel.mediaPreviewImageViewControllerDelegate = self pagingViewConttroller.interPageSpacing = 10 pagingViewConttroller.delegate = self pagingViewConttroller.dataSource = viewModel + + closeButton.addTarget(self, action: #selector(MediaPreviewViewController.closeButtonPressed(_:)), for: .touchUpInside) + + // bind view model + viewModel.currentPage + .receive(on: DispatchQueue.main) + .sink { [weak self] index in + guard let self = self else { return } + switch self.viewModel.pushTransitionItem.source { + case .mosaic(let mosaicImageViewContainer): + UIView.animate(withDuration: 0.3) { + mosaicImageViewContainer.setImageViews(alpha: 1) + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } + } + } + .store(in: &disposeBag) + } + +} + +extension MediaPreviewViewController { + + @objc private func closeButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + dismiss(animated: true, completion: nil) } } @@ -61,20 +126,19 @@ extension MediaPreviewViewController { extension MediaPreviewViewController: MediaPreviewingViewController { func isInteractiveDismissable() -> Bool { - return true -// if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController { -// let previewImageView = mediaPreviewImageViewController.previewImageView -// // TODO: allow zooming pan dismiss -// guard previewImageView.zoomScale == previewImageView.minimumZoomScale else { -// return false -// } -// -// let safeAreaInsets = previewImageView.safeAreaInsets -// let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 -// return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) -// } -// -// return false + if let mediaPreviewImageViewController = pagingViewConttroller.currentViewController as? MediaPreviewImageViewController { + let previewImageView = mediaPreviewImageViewController.previewImageView + // TODO: allow zooming pan dismiss + guard previewImageView.zoomScale == previewImageView.minimumZoomScale else { + return false + } + + let safeAreaInsets = previewImageView.safeAreaInsets + let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 + return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + } + + return false } } @@ -107,6 +171,7 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { ) { // update page control // pageControl.currentPage = index + viewModel.currentPage.value = index } func pageboyViewController( diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 744eab446..2fe7f8327 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -17,11 +17,13 @@ final class MediaPreviewViewModel: NSObject { let context: AppContext let initialItem: PreviewItem weak var mediaPreviewImageViewControllerDelegate: MediaPreviewImageViewControllerDelegate? - + let currentPage: CurrentValueSubject + // output + let pushTransitionItem: MediaPreviewTransitionItem let viewControllers: [UIViewController] - init(context: AppContext, meta: StatusImagePreviewMeta) { + init(context: AppContext, meta: StatusImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { self.context = context self.initialItem = .status(meta) var viewControllers: [UIViewController] = [] @@ -45,6 +47,8 @@ final class MediaPreviewViewModel: NSObject { } } self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(meta.initialIndex) + self.pushTransitionItem = pushTransitionItem super.init() } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift index 9d3b75b93..0f2ba82fb 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -45,7 +45,7 @@ extension MediaPreviewImageView { isUserInteractionEnabled = true showsVerticalScrollIndicator = false showsHorizontalScrollIndicator = false - + bouncesZoom = true minimumZoomScale = 1.0 maximumZoomScale = 4.0 @@ -139,6 +139,9 @@ extension MediaPreviewImageView: UIScrollViewDelegate { func scrollViewDidZoom(_ scrollView: UIScrollView) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) centerScrollViewContents() + + // set bounce when zoom in + alwaysBounceVertical = zoomScale > minimumZoomScale } func viewForZooming(in scrollView: UIScrollView) -> UIView? { diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 54e25ed87..bec55cd78 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -296,6 +296,25 @@ extension MosaicImageViewContainer { } +// FIXME: set imageView source from blurhash and image +extension MosaicImageViewContainer { + + func setImageViews(alpha: CGFloat) { + // blurhashOverlayImageViews.forEach { $0.alpha = alpha } + imageViews.forEach { $0.alpha = alpha } + } + + func setImageView(alpha: CGFloat, index: Int) { + // if index < blurhashOverlayImageViews.count { + // blurhashOverlayImageViews[index].alpha = alpha + // } + if index < imageViews.count { + imageViews[index].alpha = alpha + } + } + +} + extension MosaicImageViewContainer { @objc private func visualEffectViewTapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index 26e426add..c2ad3d4f6 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -48,29 +48,35 @@ struct MosaicMeta { func blurhashImagePublisher() -> AnyPublisher { return Future { promise in - guard let blurhash = blurhash else { - promise(.success(nil)) - return - } - - let imageSize: CGSize = { - let aspectRadio = size.width / size.height - if size.width > size.height { - let width: CGFloat = MosaicMeta.edgeMaxLength - let height = width / aspectRadio - return CGSize(width: width, height: height) - } else { - let height: CGFloat = MosaicMeta.edgeMaxLength - let width = height * aspectRadio - return CGSize(width: width, height: height) - } - }() - workingQueue.async { - let image = UIImage(blurHash: blurhash, size: imageSize) + let image = self.blurhashImage() promise(.success(image)) } } .eraseToAnyPublisher() } + + func blurhashImage() -> UIImage? { + guard let blurhash = blurhash else { + return nil + } + + let imageSize: CGSize = { + let aspectRadio = size.width / size.height + if size.width > size.height { + let width: CGFloat = MosaicMeta.edgeMaxLength + let height = width / aspectRadio + return CGSize(width: width, height: height) + } else { + let height: CGFloat = MosaicMeta.edgeMaxLength + let width = height * aspectRadio + return CGSize(width: width, height: height) + } + }() + + let image = UIImage(blurHash: blurhash, size: imageSize) + + return image + } + } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 21142ad96..066783305 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -7,10 +7,10 @@ import os.log import UIKit +import func AVFoundation.AVMakeRect final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewControllerAnimatedTransitioning { - let transitionItem: MediaPreviewTransitionItem let panGestureRecognizer: UIPanGestureRecognizer @@ -32,7 +32,6 @@ final class MediaHostToMediaPreviewViewControllerAnimatedTransitioning: ViewCont } - // MARK: - UIViewControllerAnimatedTransitioning extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { @@ -51,19 +50,48 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let toView = transitionContext.view(forKey: .to) else { fatalError() } - + let toViewEndFrame = transitionContext.finalFrame(for: toVC) toView.frame = toViewEndFrame toView.alpha = 0 transitionContext.containerView.addSubview(toView) - - let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve) + // set to image hidden + toVC.pagingViewConttroller.view.alpha = 0 + // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` + switch transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageView(alpha: 0, index: toVC.viewModel.currentPage.value) + } + + // Set transition image view + assert(transitionItem.initialFrame != nil) + let initialFrame = transitionItem.initialFrame ?? toViewEndFrame + let transitionTargetFrame: CGRect = { + let aspectRatio = transitionItem.aspectRatio ?? CGSize(width: initialFrame.width, height: initialFrame.height) + return AVMakeRect(aspectRatio: aspectRatio, insideRect: toView.bounds) + }() + let transitionImageView: UIImageView = { + let imageView = UIImageView(frame: transitionContext.containerView.convert(initialFrame, from: nil)) + imageView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.isUserInteractionEnabled = false + imageView.image = transitionItem.image + return imageView + }() + transitionItem.targetFrame = transitionTargetFrame + transitionItem.imageView = transitionImageView + transitionContext.containerView.addSubview(transitionImageView) + + let animator = MediaHostToMediaPreviewViewControllerAnimatedTransitioning.animator(initialVelocity: .zero) animator.addAnimations { + transitionImageView.frame = transitionTargetFrame toView.alpha = 1 } animator.addCompletion { position in + toVC.pagingViewConttroller.view.alpha = 1 + transitionImageView.removeFromSuperview() transitionContext.completeTransition(position == .end) } @@ -72,17 +100,59 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { private func popTransition(using transitionContext: UIViewControllerContextTransitioning, curve: UIView.AnimationCurve = .easeInOut) -> UIViewPropertyAnimator { guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let fromView = transitionContext.view(forKey: .from) else { + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { fatalError() } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + fatalError() + } + mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView) + + snapshot.center = transitionContext.containerView.center - let animator = UIViewPropertyAnimator(duration: transitionDuration(using: transitionContext), curve: curve) + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = targetFrame + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + + let animator = popInteractiveTransitionAnimator + + self.transitionItem.snapshotRaw?.alpha = 0.0 animator.addAnimations { - fromView.alpha = 0 + if let targetFrame = targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + fromView.alpha = 0 + } + fromVC.closeButtonBackground.alpha = 0 + fromVC.visualEffectView.effect = nil } animator.addCompletion { position in + self.transitionItem.snapshotTransitioning?.removeFromSuperview() + switch self.transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageViews(alpha: 1) + } transitionContext.completeTransition(position == .end) } @@ -99,35 +169,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { switch operation { case .pop: - guard let mediaPreviewViewController = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let mediaPreviewImageViewController = mediaPreviewViewController.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController else { - transitionContext.completeTransition(false) - return - } - - let imageView = mediaPreviewImageViewController.previewImageView.imageView - let _snapshot: UIView? = { -// if imageView.image == nil { -// transitionItem.snapshotRaw = mediaPreviewImageViewController.progressBarView -// return mediaPreviewImageViewController.progressBarView.snapshotView(afterScreenUpdates: false) -// } else { - transitionItem.snapshotRaw = imageView - return imageView.snapshotView(afterScreenUpdates: false) -// } - }() - guard let snapshot = _snapshot else { - transitionContext.completeTransition(false) - return - } - mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView) - - snapshot.center = transitionContext.containerView.center - - transitionItem.imageView = imageView - transitionItem.snapshotTransitioning = snapshot - transitionItem.initialFrame = snapshot.frame - transitionItem.targetFrame = snapshot.frame - + // Note: change item.imageView transform via pan gesture panGestureRecognizer.addTarget(self, action: #selector(MediaHostToMediaPreviewViewControllerAnimatedTransitioning.updatePanGestureInteractive(_:))) popInteractiveTransition(using: transitionContext) default: @@ -138,27 +180,62 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { private func popInteractiveTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let fromVC = transitionContext.viewController(forKey: .from) as? MediaPreviewViewController, - let fromView = transitionContext.view(forKey: .from) else { + let fromView = transitionContext.view(forKey: .from), + let mediaPreviewImageViewController = fromVC.pagingViewConttroller.currentViewController as? MediaPreviewImageViewController, + let index = fromVC.pagingViewConttroller.currentIndex else { fatalError() } + + // assert view hierarchy not change + let toVC = transitionItem.previewableViewController + let targetFrame = toVC.sourceFrame(transitionItem: transitionItem, index: index) + + let imageView = mediaPreviewImageViewController.previewImageView.imageView + let _snapshot: UIView? = { + transitionItem.snapshotRaw = imageView + let snapshot = imageView.snapshotView(afterScreenUpdates: false) + snapshot?.clipsToBounds = true + snapshot?.contentMode = .scaleAspectFill + return snapshot + }() + guard let snapshot = _snapshot else { + transitionContext.completeTransition(false) + return + } + mediaPreviewImageViewController.view.insertSubview(snapshot, aboveSubview: mediaPreviewImageViewController.previewImageView) + + snapshot.center = transitionContext.containerView.center + transitionItem.imageView = imageView + transitionItem.snapshotTransitioning = snapshot + transitionItem.initialFrame = snapshot.frame + transitionItem.targetFrame = targetFrame + + // disable interaction + fromVC.pagingViewConttroller.isUserInteractionEnabled = false + let animator = popInteractiveTransitionAnimator let blurEffect = fromVC.visualEffectView.effect - self.transitionItem.imageView?.isHidden = true self.transitionItem.snapshotRaw?.alpha = 0.0 + animator.addAnimations { - self.transitionItem.snapshotTransitioning?.alpha = 0.4 -// fromVC.mediaInfoDescriptionView.alpha = 0 -// fromVC.closeButtonBackground.alpha = 0 -// fromVC.pageControl.alpha = 0 + fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil } animator.addCompletion { position in + fromVC.pagingViewConttroller.isUserInteractionEnabled = true + fromVC.closeButtonBackground.alpha = position == .end ? 0 : 1 self.transitionItem.imageView?.isHidden = position == .end self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 self.transitionItem.snapshotTransitioning?.removeFromSuperview() + if position == .end { + switch self.transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + mosaicImageViewContainer.setImageViews(alpha: 1) + } + } fromVC.visualEffectView.effect = position == .end ? nil : blurEffect transitionContext.completeTransition(position == .end) } @@ -184,10 +261,10 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { case .ended, .cancelled: let targetPosition = completionPosition() os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: target position: %s", ((#file as NSString).lastPathComponent), #line, #function, targetPosition == .end ? "end" : "start") - targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() isTransitionContextFinish = true animate(targetPosition) + targetPosition == .end ? transitionContext.finishInteractiveTransition() : transitionContext.cancelInteractiveTransition() default: return } @@ -239,10 +316,17 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { itemAnimator.addAnimations { if toPosition == .end { - self.transitionItem.snapshotTransitioning?.alpha = 0 + if let targetFrame = self.transitionItem.targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } } else { - self.transitionItem.snapshotTransitioning?.alpha = 1 - self.transitionItem.snapshotTransitioning?.frame = self.transitionItem.initialFrame! + if let initialFrame = self.transitionItem.initialFrame { + self.transitionItem.snapshotTransitioning?.frame = initialFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 1 + } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index 83b008eef..c1de3023b 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -74,10 +74,9 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat self.mediaPreviewViewController = mediaPreviewViewController self.mediaPreviewViewController?.view.addGestureRecognizer(panGestureRecognizer) - let transitionItem = MediaPreviewTransitionItem(id: UUID()) return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .push, - transitionItem: transitionItem, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, panGestureRecognizer: panGestureRecognizer ) } @@ -92,10 +91,10 @@ extension MediaPreviewTransitionController: UIViewControllerTransitioningDelegat assertionFailure() return nil } - let transitionItem = MediaPreviewTransitionItem(id: UUID()) + return MediaHostToMediaPreviewViewControllerAnimatedTransitioning( operation: .pop, - transitionItem: transitionItem, + transitionItem: mediaPreviewViewController.viewModel.pushTransitionItem, panGestureRecognizer: panGestureRecognizer ) } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index de9f0fbfb..73afeeb8f 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -6,21 +6,40 @@ // import UIKit +import CoreData class MediaPreviewTransitionItem: Identifiable { let id: UUID + let source: Source + var previewableViewController: MediaPreviewableViewController - // TODO: + // source + // value maybe invalid when preview paging + var image: UIImage? + var aspectRatio: CGSize? + var initialFrame: CGRect? = nil + var sourceImageView: UIImageView? + + // target + var targetFrame: CGRect? = nil + + // transitioning var imageView: UIImageView? var snapshotRaw: UIView? var snapshotTransitioning: UIView? - var initialFrame: CGRect? = nil - var targetFrame: CGRect? = nil var touchOffset: CGVector = CGVector.zero - init(id: UUID) { + init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { self.id = id + self.source = source + self.previewableViewController = previewableViewController } } + +extension MediaPreviewTransitionItem { + enum Source { + case mosaic(MosaicImageViewContainer) + } +} diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 7e4c39d06..e5eb8ba6c 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -5,8 +5,20 @@ // Created by MainasuK Cirno on 2021-4-28. // -import Foundation +import UIKit -protocol MediaPreviewableViewController: class { +protocol MediaPreviewableViewController: AnyObject { var mediaPreviewTransitionController: MediaPreviewTransitionController { get } + func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? +} + +extension MediaPreviewableViewController { + func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? { + switch transitionItem.source { + case .mosaic(let mosaicImageViewContainer): + guard index < mosaicImageViewContainer.imageViews.count else { return nil } + let imageView = mosaicImageViewContainer.imageViews[index] + return imageView.superview!.convert(imageView.frame, to: nil) + } + } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift index 1b92e5127..9c6e56d05 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewingViewController.swift @@ -7,6 +7,6 @@ import Foundation -protocol MediaPreviewingViewController: class { +protocol MediaPreviewingViewController: AnyObject { func isInteractiveDismissable() -> Bool } From 23491e60b90c0a87673cf08648822981aa25dc85 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 28 Apr 2021 16:18:20 +0800 Subject: [PATCH 342/400] chore: add userProvider chore: add userProvider --- Mastodon.xcodeproj/project.pbxproj | 48 +++++++++---------- .../Protocol/UserProvider/UserProvider.swift | 21 ++++++++ .../UserProvider/UserProviderFacade.swift | 36 +++++++------- ...htagTimelineViewController+Provider.swift} | 3 +- ...HomeTimelineViewController+Provider.swift} | 4 +- ... => FavoriteViewController+Provider.swift} | 2 + .../ProfileViewController+UserProvider.swift | 7 +++ ...UserTimelineViewController+Provider.swift} | 4 +- ...blicTimelineViewController+Provider.swift} | 4 +- .../Search/SearchViewController+Follow.swift | 7 +++ .../TableviewCell/StatusTableViewCell.swift | 2 +- ...ft => ThreadViewController+Provider.swift} | 4 +- 12 files changed, 94 insertions(+), 48 deletions(-) rename Mastodon/Scene/HashtagTimeline/{HashtagTimelineViewController+StatusProvider.swift => HashtagTimelineViewController+Provider.swift} (96%) rename Mastodon/Scene/HomeTimeline/{HomeTimelineViewController+StatusProvider.swift => HomeTimelineViewController+Provider.swift} (96%) rename Mastodon/Scene/Profile/Favorite/{FavoriteViewController+StatusProvider.swift => FavoriteViewController+Provider.swift} (98%) rename Mastodon/Scene/Profile/Timeline/{UserTimelineViewController+StatusProvider.swift => UserTimelineViewController+Provider.swift} (96%) rename Mastodon/Scene/PublicTimeline/{PublicTimelineViewController+StatusProvider.swift => PublicTimelineViewController+Provider.swift} (96%) rename Mastodon/Scene/Thread/{ThreadViewController+StatusProvider.swift => ThreadViewController+Provider.swift} (96%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 041a837c5..99ca696f1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -13,7 +13,7 @@ 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */; }; 0F20220D26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */; }; 0F202213261351F5000C64BF /* APIService+HashtagTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */; }; - 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */; }; + 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */; }; 0F20222D261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */; }; 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */; }; 0F20223926146553000C64BF /* Array+removeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array+removeDuplicates.swift */; }; @@ -59,7 +59,7 @@ 2D364F7825E66D8300204FDC /* MastodonResendEmailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */; }; 2D38F1C625CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */; }; 2D38F1D525CD465300561493 /* HomeTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */; }; - 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */; }; + 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */; }; 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */; }; 2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */; }; 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */; }; @@ -98,7 +98,7 @@ 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */; }; 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */; }; 2D76316B25C14D4C00929FB9 /* PublicTimelineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */; }; - 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */; }; + 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */; }; 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */; }; 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; }; 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; }; @@ -233,7 +233,7 @@ DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */; }; DB47229725F9EFAD00DA7F53 /* NSManagedObjectContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */; }; DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; - DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */; }; + DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; DB49A61425FF2C5600B98345 /* EmojiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61325FF2C5600B98345 /* EmojiService.swift */; }; DB49A61F25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */; }; @@ -311,7 +311,7 @@ DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */; }; DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */; }; DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */; }; - DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */; }; + DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */; }; DB938F3326243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */; }; DB98336B25C9420100AD9700 /* APIService+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98336A25C9420100AD9700 /* APIService+App.swift */; }; DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98337025C9443200AD9700 /* APIService+Authentication.swift */; }; @@ -378,7 +378,7 @@ DBE3CDFB261C6CA500430CC6 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */; }; DBE3CE01261D623D00430CC6 /* FavoriteViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */; }; DBE3CE07261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */; }; - DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */; }; + DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */; }; DBE3CE13261D7D4200430CC6 /* StatusTableViewControllerAspect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */; }; DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -444,7 +444,7 @@ 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; 0F202212261351F5000C64BF /* APIService+HashtagTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HashtagTimeline.swift"; sourceTree = ""; }; - 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewController+Provider.swift"; sourceTree = ""; }; 0F20222C261457EE000C64BF /* HashtagTimelineViewModel+LoadOldestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadOldestState.swift"; sourceTree = ""; }; 0F20223226145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HashtagTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; 0F20223826146553000C64BF /* Array+removeDuplicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+removeDuplicates.swift"; sourceTree = ""; }; @@ -489,7 +489,7 @@ 2D364F7725E66D8300204FDC /* MastodonResendEmailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModel.swift; sourceTree = ""; }; 2D38F1C525CD37F400561493 /* ContentOffsetAdjustableTimelineViewControllerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentOffsetAdjustableTimelineViewControllerDelegate.swift; sourceTree = ""; }; 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewController.swift; sourceTree = ""; }; - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewController+Provider.swift"; sourceTree = ""; }; 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTimelineViewModel.swift; sourceTree = ""; }; 2D38F1EA25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadLatestState.swift"; sourceTree = ""; }; 2D38F1F025CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeTimelineViewModel+LoadMiddleState.swift"; sourceTree = ""; }; @@ -525,7 +525,7 @@ 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewController.swift; sourceTree = ""; }; 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineViewModel.swift; sourceTree = ""; }; - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewController+Provider.swift"; sourceTree = ""; }; 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicTimelineViewModel+Diffable.swift"; sourceTree = ""; }; 2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = ""; }; 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; @@ -671,7 +671,7 @@ DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+HomeTimeline.swift"; sourceTree = ""; }; DB47229625F9EFAD00DA7F53 /* NSManagedObjectContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSManagedObjectContext.swift; sourceTree = ""; }; DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewModel+State.swift"; sourceTree = ""; }; - DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+StatusProvider.swift"; sourceTree = ""; }; + DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserTimelineViewController+Provider.swift"; sourceTree = ""; }; DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+UserTimeline.swift"; sourceTree = ""; }; DB49A61325FF2C5600B98345 /* EmojiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiService.swift; sourceTree = ""; }; DB49A61E25FF32AA00B98345 /* EmojiService+CustomEmojiViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmojiService+CustomEmojiViewModel.swift"; sourceTree = ""; }; @@ -750,7 +750,7 @@ DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+LoadThreadState.swift"; sourceTree = ""; }; DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Thread.swift"; sourceTree = ""; }; DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewModel+Diffable.swift"; sourceTree = ""; }; - DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+StatusProvider.swift"; sourceTree = ""; }; + DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThreadViewController+Provider.swift"; sourceTree = ""; }; DB938F3226243D6200E5B6C1 /* TimelineTopLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTopLoaderTableViewCell.swift; sourceTree = ""; }; DB98336A25C9420100AD9700 /* APIService+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+App.swift"; sourceTree = ""; }; DB98337025C9443200AD9700 /* APIService+Authentication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Authentication.swift"; sourceTree = ""; }; @@ -816,7 +816,7 @@ DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+State.swift"; sourceTree = ""; }; DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewModel+Diffable.swift"; sourceTree = ""; }; - DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+StatusProvider.swift"; sourceTree = ""; }; + DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoriteViewController+Provider.swift"; sourceTree = ""; }; DBE3CE12261D7D4200430CC6 /* StatusTableViewControllerAspect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewControllerAspect.swift; sourceTree = ""; }; DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = Mastodon.xctestplan; path = Mastodon/Mastodon.xctestplan; sourceTree = ""; }; DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MastodonSDK.xctestplan; sourceTree = ""; }; @@ -883,7 +883,7 @@ isa = PBXGroup; children = ( 0F2021FA2613262F000C64BF /* HashtagTimelineViewController.swift */, - 0F202226261411BA000C64BF /* HashtagTimelineViewController+StatusProvider.swift */, + 0F202226261411BA000C64BF /* HashtagTimelineViewController+Provider.swift */, 0F202200261326E6000C64BF /* HashtagTimelineViewModel.swift */, 0F20220626134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift */, 0F20220C26134E3F000C64BF /* HashtagTimelineViewModel+LoadLatestState.swift */, @@ -1005,7 +1005,7 @@ children = ( DB1F239626117C360057430E /* View */, 2D38F1D425CD465300561493 /* HomeTimelineViewController.swift */, - 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift */, + 2D38F1DE25CD46A400561493 /* HomeTimelineViewController+Provider.swift */, 2D5A3D6125CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift */, 2D38F1E425CD46C100561493 /* HomeTimelineViewModel.swift */, 2D5A3D2725CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift */, @@ -1126,7 +1126,7 @@ isa = PBXGroup; children = ( 2D76316425C14BD100929FB9 /* PublicTimelineViewController.swift */, - 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+StatusProvider.swift */, + 2D76317C25C14DF400929FB9 /* PublicTimelineViewController+Provider.swift */, 2D76316A25C14D4C00929FB9 /* PublicTimelineViewModel.swift */, 2D32EAD925CBCC3300C9ED86 /* PublicTimelineViewModel+LoadMiddleState.swift */, 2D76318225C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift */, @@ -1803,7 +1803,7 @@ isa = PBXGroup; children = ( DB938EE52623F50700E5B6C1 /* ThreadViewController.swift */, - DB938F24262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift */, + DB938F24262438D600E5B6C1 /* ThreadViewController+Provider.swift */, DB938EEC2623F79B00E5B6C1 /* ThreadViewModel.swift */, DB938F1E2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift */, DB938F0E2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift */, @@ -1968,7 +1968,7 @@ isa = PBXGroup; children = ( DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */, - DB482A44261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift */, + DB482A44261335BA008AE74C /* UserTimelineViewController+Provider.swift */, DBB525552611EDCA002F1F29 /* UserTimelineViewModel.swift */, DBCBED1626132DB500B49291 /* UserTimelineViewModel+Diffable.swift */, DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */, @@ -2020,7 +2020,7 @@ isa = PBXGroup; children = ( DBE3CDEB261C6B2900430CC6 /* FavoriteViewController.swift */, - DBE3CE0C261D767100430CC6 /* FavoriteViewController+StatusProvider.swift */, + DBE3CE0C261D767100430CC6 /* FavoriteViewController+Provider.swift */, DBE3CDFA261C6CA500430CC6 /* FavoriteViewModel.swift */, DBE3CE06261D6A0E00430CC6 /* FavoriteViewModel+Diffable.swift */, DBE3CE00261D623D00430CC6 /* FavoriteViewModel+State.swift */, @@ -2500,7 +2500,7 @@ 0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, - DBE3CE0D261D767100430CC6 /* FavoriteViewController+StatusProvider.swift in Sources */, + DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, @@ -2533,7 +2533,7 @@ 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */, 0FB3D2FE25E4CB6400AAD544 /* PickServerTitleCell.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, - 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+StatusProvider.swift in Sources */, + 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, @@ -2606,7 +2606,7 @@ DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */, 2D34D9CB261489930081BFC0 /* SearchViewController+Recommend.swift in Sources */, DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, - DB482A45261335BA008AE74C /* UserTimelineViewController+StatusProvider.swift in Sources */, + DB482A45261335BA008AE74C /* UserTimelineViewController+Provider.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9A485C2603010E008B817C /* PHPickerResultLoader.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, @@ -2647,7 +2647,7 @@ DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */, 2D34D9E226149C920081BFC0 /* SearchRecommendTagsCollectionViewCell.swift in Sources */, - 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+StatusProvider.swift in Sources */, + 2D76317D25C14DF500929FB9 /* PublicTimelineViewController+Provider.swift in Sources */, 0F20223326145E51000C64BF /* HashtagTimelineViewModel+LoadMiddleState.swift in Sources */, 2D206B8025F5F45E00143C56 /* UIImage.swift in Sources */, 0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */, @@ -2661,7 +2661,7 @@ 2DA7D04A25CA52CB00804E11 /* TimelineBottomLoaderTableViewCell.swift in Sources */, 2D76318325C14E8F00929FB9 /* PublicTimelineViewModel+Diffable.swift in Sources */, 2D198655261C3C4300F0B013 /* SearchViewModel+LoadOldestState.swift in Sources */, - 0F202227261411BB000C64BF /* HashtagTimelineViewController+StatusProvider.swift in Sources */, + 0F202227261411BB000C64BF /* HashtagTimelineViewController+Provider.swift in Sources */, 2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */, 2D76316525C14BD100929FB9 /* PublicTimelineViewController.swift in Sources */, DB938F1F2624382F00E5B6C1 /* ThreadViewModel+Diffable.swift in Sources */, @@ -2692,7 +2692,7 @@ 2D38F1FE25CD481700561493 /* StatusProvider.swift in Sources */, 5B24BBE2262DB19100A9381B /* APIService+Report.swift in Sources */, 5BB04FF5262F0E6D0043BFF6 /* ReportSection.swift in Sources */, - DB938F25262438D600E5B6C1 /* ThreadViewController+StatusProvider.swift in Sources */, + DB938F25262438D600E5B6C1 /* ThreadViewController+Provider.swift in Sources */, DB66729C25F9F91F00D60309 /* ComposeStatusItem.swift in Sources */, 0FB3D31E25E534C700AAD544 /* PickServerCategoryCollectionViewCell.swift in Sources */, 5DF1057925F88A1D00D6C0D4 /* PlayerContainerView.swift in Sources */, diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift index 63a1f8e68..7426d89ac 100644 --- a/Mastodon/Protocol/UserProvider/UserProvider.swift +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -13,4 +13,25 @@ import CoreDataStack protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async func mastodonUser() -> Future + + func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future +} + +extension UserProvider where Self: StatusProvider { + func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + return Future { [weak self] promise in + guard let self = self else { return } + self.status(for: cell, indexPath: indexPath) + .sink { status in + promise(.success(status?.author)) + } + .store(in: &self.disposeBag) + } + } + + func mastodonUser() -> Future { + return Future { promise in + promise(.success(nil)) + } + } } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 4e8227f69..729e2a932 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -202,24 +202,7 @@ extension UserProviderFacade { children.append(blockMenu) } - if needsShareAction { - let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider) - provider.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: sourceView, - barButtonItem: barButtonItem - ), - from: provider, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } - children.append(shareAction) - } - - let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "flag"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return @@ -237,6 +220,23 @@ extension UserProviderFacade { } children.append(reportAction) + if needsShareAction { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } + return UIMenu(title: "", options: [], children: children) } diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift similarity index 96% rename from Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift rename to Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift index 191ad374d..3bc3a36b5 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift @@ -1,5 +1,5 @@ // -// HashtagTimelineViewController+StatusProvider.swift +// HashtagTimelineViewController+Provider.swift // Mastodon // // Created by BradGao on 2021/3/31. @@ -86,3 +86,4 @@ extension HashtagTimelineViewController: StatusProvider { } +extension HashtagTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift similarity index 96% rename from Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift rename to Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift index aea931a62..d735d5843 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -1,5 +1,5 @@ // -// HomeTimelineViewController+StatusProvider.swift +// HomeTimelineViewController+Provider.swift // Mastodon // // Created by sxiaojian on 2021/2/5. @@ -85,3 +85,5 @@ extension HomeTimelineViewController: StatusProvider { } } + +extension HomeTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift similarity index 98% rename from Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift rename to Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift index 68adc1e3e..88f368c15 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift @@ -85,3 +85,5 @@ extension FavoriteViewController: StatusProvider { } } + +extension FavoriteViewController: UserProvider {} diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift index 3a26db1c1..be124a0c2 100644 --- a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -8,8 +8,15 @@ import Foundation import Combine import CoreDataStack +import UIKit extension ProfileViewController: UserProvider { + func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + return Future { promise in + promise(.success(nil)) + } + } + func mastodonUser() -> Future { return Future { promise in diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift similarity index 96% rename from Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift rename to Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift index 4fc857812..30029ae5b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift @@ -1,5 +1,5 @@ // -// UserTimelineViewController+StatusProvider.swift +// UserTimelineViewController+Provider.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-30. @@ -85,3 +85,5 @@ extension UserTimelineViewController: StatusProvider { } } + +extension UserTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift similarity index 96% rename from Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift rename to Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift index 04fc526a0..96963914c 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+StatusProvider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift @@ -1,5 +1,5 @@ // -// PublicTimelineViewController+StatusProvider.swift +// PublicTimelineViewController+Provider.swift // Mastodon // // Created by sxiaojian on 2021/1/27. @@ -85,3 +85,5 @@ extension PublicTimelineViewController: StatusProvider { } } + +extension PublicTimelineViewController: UserProvider {} diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index 8b0acda0a..8986dd680 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -11,6 +11,13 @@ import Foundation import UIKit extension SearchViewController: UserProvider { + + func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + return Future { promise in + promise(.success(nil)) + } + } + func mastodonUser() -> Future { Future { promise in promise(.success(self.viewModel.mastodonUser.value)) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d546fea62..3ab03f3ee 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -344,7 +344,7 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { } func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { - + } } diff --git a/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift similarity index 96% rename from Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift rename to Mastodon/Scene/Thread/ThreadViewController+Provider.swift index 05cc6e4b2..a76a22d0b 100644 --- a/Mastodon/Scene/Thread/ThreadViewController+StatusProvider.swift +++ b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift @@ -1,5 +1,5 @@ // -// ThreadViewController+StatusProvider.swift +// ThreadViewController+Provider.swift // Mastodon // // Created by MainasuK Cirno on 2021-4-12. @@ -86,3 +86,5 @@ extension ThreadViewController: StatusProvider { } } + +extension ThreadViewController: UserProvider {} From 273305cda9533b09828c986b9d8c30138034c74f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Wed, 28 Apr 2021 19:56:30 +0800 Subject: [PATCH 343/400] chore: show menu in statusCell --- .../Section/NotificationSection.swift | 1 + .../Diffiable/Section/ReportSection.swift | 1 + .../Diffiable/Section/StatusSection.swift | 73 +++++++------- .../UserProvider/UserProviderFacade.swift | 99 ++++++++++++------- .../Scene/Profile/ProfileViewController.swift | 17 +++- .../Search/SearchViewController+Follow.swift | 4 +- 6 files changed, 119 insertions(+), 76 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 9c59350b4..20db29c93 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -50,6 +50,7 @@ extension NotificationSection { let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) StatusSection.configure( cell: cell, + indexPath: indexPath, dependency: dependency, readableLayoutFrame: frame, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index 6faaae6c2..07321be96 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -41,6 +41,7 @@ extension ReportSection { let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, + indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b897de47f..36eecdbe5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -49,6 +49,7 @@ extension StatusSection { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex StatusSection.configure( cell: cell, + indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, @@ -71,6 +72,7 @@ extension StatusSection { let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, + indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, @@ -136,6 +138,7 @@ extension StatusSection { static func configure( cell: StatusCell, + indexPath: IndexPath, dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, @@ -223,7 +226,6 @@ extension StatusSection { meta.blurhashImagePublisher() .receive(on: DispatchQueue.main) .sink { [weak cell] image in - guard let cell = cell else { return } blurhashOverlayImageView.image = image image?.pngData().flatMap { blurhashImageCache.setObject($0 as NSData, forKey: blurhashImageDataKey) @@ -401,16 +403,16 @@ extension StatusSection { .store(in: &cell.disposeBag) } - // toolbar - StatusSection.configureActionToolBar( - cell: cell, - dependency: dependency, - status: status, - requestUserID: requestUserID - ) - - // separator line if let statusTableViewCell = cell as? StatusTableViewCell { + // toolbar + StatusSection.configureActionToolBar( + cell: statusTableViewCell, + indexPath: indexPath, + dependency: dependency, + status: status, + requestUserID: requestUserID + ) + // separator line statusTableViewCell.separatorLine.isHidden = statusItemAttribute.isSeparatorLineHidden } @@ -434,8 +436,10 @@ extension StatusSection { guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, let status = object as? Status else { return } + guard let cell = cell as? StatusTableViewCell else { return } StatusSection.configureActionToolBar( cell: cell, + indexPath: indexPath, dependency: dependency, status: status, requestUserID: requestUserID @@ -593,7 +597,8 @@ extension StatusSection { } static func configureActionToolBar( - cell: StatusCell, + cell: StatusTableViewCell, + indexPath: IndexPath, dependency: NeedsDependency, status: Status, requestUserID: String @@ -623,7 +628,7 @@ extension StatusSection { cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike - self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) + self.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) } static func configurePoll( @@ -752,37 +757,35 @@ extension StatusSection { } private static func setupStatusMoreButtonMenu( - cell: StatusCell, + cell: StatusTableViewCell, + indexPath: IndexPath, dependency: NeedsDependency, status: Status) { - cell.statusView.actionToolbarContainer.moreButton.menu = nil + guard let userProvider = dependency as? UserProvider else { fatalError() } guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else { return } let author = (status.reblog ?? status).author - guard authenticationBox.userID != author.id else { - return - } - var children: [UIMenuElement] = [] - let name = author.displayNameWithFallback - let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "exclamationmark.bubble"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { - [weak dependency] _ in - guard let dependency = dependency else { return } - let viewModel = ReportViewModel( - context: dependency.context, - domain: authenticationBox.domain, - user: status.author, - status: status) - dependency.coordinator.present( - scene: .report(viewModel: viewModel), - from: nil, - transition: .modal(animated: true, completion: nil) - ) - } - children.append(reportAction) - cell.statusView.actionToolbarContainer.moreButton.menu = UIMenu(title: "", options: [], children: children) + let canReport = authenticationBox.userID != author.id + + let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) + let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) + cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true + cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( + for: author, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: canReport, + provider: userProvider, + cell: cell, + indexPath: indexPath, + sourceView: cell.statusView.actionToolbarContainer.moreButton, + barButtonItem: nil, + shareUser: nil, + shareStatus: status + ) } } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 729e2a932..0d839bd17 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -57,19 +57,28 @@ extension UserProviderFacade { extension UserProviderFacade { static func toggleUserBlockRelationship( - provider: UserProvider + provider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? ) -> AnyPublisher, Error> { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } - - return _toggleUserBlockRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser().eraseToAnyPublisher() - ) + if let cell = cell, let indexPath = indexPath { + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + ) + } else { + return _toggleUserBlockRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } } private static func _toggleUserBlockRelationship( @@ -97,19 +106,28 @@ extension UserProviderFacade { extension UserProviderFacade { static func toggleUserMuteRelationship( - provider: UserProvider + provider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? ) -> AnyPublisher, Error> { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } - - return _toggleUserMuteRelationship( - context: provider.context, - activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser().eraseToAnyPublisher() - ) + if let cell = cell, let indexPath = indexPath { + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + ) + } else { + return _toggleUserMuteRelationship( + context: provider.context, + activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, + mastodonUser: provider.mastodonUser().eraseToAnyPublisher() + ) + } } private static func _toggleUserMuteRelationship( @@ -140,10 +158,14 @@ extension UserProviderFacade { for mastodonUser: MastodonUser, isMuting: Bool, isBlocking: Bool, - needsShareAction: Bool, + canReport: Bool, provider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath?, sourceView: UIView?, - barButtonItem: UIBarButtonItem? + barButtonItem: UIBarButtonItem?, + shareUser: MastodonUser?, + shareStatus: Status? ) -> UIMenu { var children: [UIMenuElement] = [] let name = mastodonUser.displayNameWithFallback @@ -159,7 +181,9 @@ extension UserProviderFacade { guard let provider = provider else { return } UserProviderFacade.toggleUserMuteRelationship( - provider: provider + provider: provider, + cell: cell, + indexPath: indexPath ) .sink { _ in // do nothing @@ -186,7 +210,9 @@ extension UserProviderFacade { guard let provider = provider else { return } UserProviderFacade.toggleUserBlockRelationship( - provider: provider + provider: provider, + cell: cell, + indexPath: indexPath ) .sink { _ in // do nothing @@ -201,29 +227,30 @@ extension UserProviderFacade { let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) children.append(blockMenu) } - - let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "flag"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { - return + if canReport { + let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "flag"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { + return + } + let viewModel = ReportViewModel( + context: provider.context, + domain: authenticationBox.domain, + user: mastodonUser, + status: nil) + provider.coordinator.present( + scene: .report(viewModel: viewModel), + from: provider, + transition: .modal(animated: true, completion: nil) + ) } - let viewModel = ReportViewModel( - context: provider.context, - domain: authenticationBox.domain, - user: mastodonUser, - status: nil) - provider.coordinator.present( - scene: .report(viewModel: viewModel), - from: provider, - transition: .modal(animated: true, completion: nil) - ) + children.append(reportAction) } - children.append(reportAction) - if needsShareAction { + if let shareUser = shareUser { let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } - let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: mastodonUser, dependency: provider) + let activityViewController = createActivityViewControllerForMastodonUser(mastodonUser: shareUser, dependency: provider) provider.coordinator.present( scene: .activityViewController( activityViewController: activityViewController, diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 8fc915a0a..7a8742f18 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -377,7 +377,18 @@ extension ProfileViewController { let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value - self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu(for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, needsShareAction: needsShareAction, provider: self, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem) + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( + for: mastodonUser, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: true, + provider: self, + cell: nil, + indexPath: nil, + sourceView: nil, + barButtonItem: self.moreMenuBarButtonItem, + shareUser: needsShareAction ? mastodonUser : nil, + shareStatus: nil) } .store(in: &disposeBag) viewModel.isRelationshipActionButtonHidden @@ -692,7 +703,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self) + UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil, indexPath: nil) .sink { _ in // do nothing } receiveValue: { _ in @@ -714,7 +725,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self) + UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil, indexPath: nil) .sink { _ in // do nothing } receiveValue: { _ in diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index 8986dd680..6682d846b 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -54,7 +54,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self) + UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil, indexPath: nil) .sink { _ in // do nothing } receiveValue: { _ in @@ -76,7 +76,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self) + UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil, indexPath: nil) .sink { _ in // do nothing } receiveValue: { _ in From acbbafb18f4ff1d54aa46f5f663402c1fd89f834 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 20:10:17 +0800 Subject: [PATCH 344/400] feat: handle profile avatar preview --- .../Protocol/AvatarConfigurableView.swift | 17 +++++++-- .../MediaPreviewViewController.swift | 2 ++ .../MediaPreview/MediaPreviewViewModel.swift | 28 ++++++++++++++- .../Image/MediaPreviewImageViewModel.swift | 6 ++-- .../Header/ProfileHeaderViewController.swift | 3 +- .../Header/View/ProfileHeaderView.swift | 32 ++++++++++++++++- .../Scene/Profile/ProfileViewController.swift | 36 ++++++++++++++++++- ...wViewControllerAnimatedTransitioning.swift | 18 ++++------ .../MediaPreviewTransitionItem.swift | 16 +++++++++ .../MediaPreviewableViewController.swift | 2 ++ 10 files changed, 138 insertions(+), 22 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 1c2c78da3..f2e954910 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -51,7 +51,10 @@ extension AvatarConfigurableView { avatarConfigurableView(self, didFinishConfiguration: configuration) } - let filter = ScaledToSizeWithRoundedCornersFilter(size: Self.configurableAvatarImageSize, radius: Self.configurableAvatarImageCornerRadius) + let filter = ScaledToSizeWithRoundedCornersFilter( + size: Self.configurableAvatarImageSize, + radius: configuration.keepImageCorner ? 0 : Self.configurableAvatarImageCornerRadius + ) // set placeholder if no asset guard let avatarImageURL = configuration.avatarImageURL else { @@ -91,6 +94,12 @@ extension AvatarConfigurableView { runImageTransitionIfCached: false, completion: nil ) + + if Self.configurableAvatarImageCornerRadius > 0, configuration.keepImageCorner { + configurableAvatarImageView?.layer.masksToBounds = true + configurableAvatarImageView?.layer.cornerRadius = Self.configurableAvatarImageCornerRadius + configurableAvatarImageView?.layer.cornerCurve = Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 ? .continuous :.circular + } } configureLayerBorder(view: avatarImageView, configuration: configuration) @@ -148,16 +157,20 @@ struct AvatarConfigurableViewConfiguration { let borderColor: UIColor? let borderWidth: CGFloat? + let keepImageCorner: Bool + init( avatarImageURL: URL?, placeholderImage: UIImage? = nil, borderColor: UIColor? = nil, - borderWidth: CGFloat? = nil + borderWidth: CGFloat? = nil, + keepImageCorner: Bool = true ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage self.borderColor = borderColor self.borderWidth = borderWidth + self.keepImageCorner = keepImageCorner } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 9a5157990..01d33853a 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -106,6 +106,8 @@ extension MediaPreviewViewController { mosaicImageViewContainer.setImageViews(alpha: 1) mosaicImageViewContainer.setImageView(alpha: 0, index: index) } + case .profileAvatar: + break } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 2fe7f8327..6a0b749b6 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -36,7 +36,7 @@ final class MediaPreviewViewModel: NSObject { switch entity.type { case .image: guard let url = URL(string: entity.url) else { continue } - let meta = MediaPreviewImageViewModel.StatusImagePreviewMeta(url: url, thumbnail: thumbnail) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel @@ -52,12 +52,33 @@ final class MediaPreviewViewModel: NSObject { super.init() } + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileAvatar(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.avatarImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + } extension MediaPreviewViewModel { enum PreviewItem { case status(StatusImagePreviewMeta) + case profileAvatar(ProfileAvatarImagePreviewMeta) case local(LocalImagePreviewMeta) } @@ -67,6 +88,11 @@ extension MediaPreviewViewModel { let preloadThumbnailImages: [UIImage?] } + struct ProfileAvatarImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + struct LocalImagePreviewMeta { let image: UIImage } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index d59cb5778..0ba7d4dc8 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -13,7 +13,7 @@ class MediaPreviewImageViewModel { // input let item: ImagePreviewItem - init(meta: StatusImagePreviewMeta) { + init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) } @@ -25,11 +25,11 @@ class MediaPreviewImageViewModel { extension MediaPreviewImageViewModel { enum ImagePreviewItem { - case status(StatusImagePreviewMeta) + case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) } - struct StatusImagePreviewMeta { + struct RemoteImagePreviewMeta { let url: URL let thumbnail: UIImage? } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 38695f34f..bb89dab07 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -150,8 +150,7 @@ extension ProfileHeaderViewController { with: AvatarConfigurableViewConfiguration( avatarImageURL: image == nil ? url : nil, // set only when image empty placeholderImage: image, - borderColor: .white, - borderWidth: 2 + keepImageCorner: true // fit preview transitioning ) ) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 09d99c51e..1f1f6b711 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -10,7 +10,8 @@ import UIKit import ActiveLabel import TwitterTextEditor -protocol ProfileHeaderViewDelegate: class { +protocol ProfileHeaderViewDelegate: AnyObject { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) @@ -23,6 +24,8 @@ final class ProfileHeaderView: UIView { static let avatarImageViewSize = CGSize(width: 56, height: 56) static let avatarImageViewCornerRadius: CGFloat = 6 + static let avatarImageViewBorderColor = UIColor.white + static let avatarImageViewBorderWidth: CGFloat = 2 static let friendshipActionButtonSize = CGSize(width: 108, height: 34) static let bannerImageViewPlaceholderColor = UIColor.systemGray @@ -51,6 +54,16 @@ final class ProfileHeaderView: UIView { return overlayView }() + let avatarImageViewBackgroundView: UIView = { + let view = UIView() + view.layer.masksToBounds = true + view.layer.cornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + view.layer.cornerCurve = .continuous + view.layer.borderColor = ProfileHeaderView.avatarImageViewBorderColor.cgColor + view.layer.borderWidth = ProfileHeaderView.avatarImageViewBorderWidth + return view + }() + let avatarImageView: UIImageView = { let imageView = UIImageView() let placeholderImage = UIImage @@ -188,6 +201,15 @@ extension ProfileHeaderView { avatarImageView.heightAnchor.constraint(equalToConstant: ProfileHeaderView.avatarImageViewSize.height).priority(.required - 1), ]) + avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false + bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) + NSLayoutConstraint.activate([ + avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + ]) + editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.addSubview(editAvatarBackgroundView) NSLayoutConstraint.activate([ @@ -313,6 +335,9 @@ extension ProfileHeaderView { bioActiveLabel.delegate = self + let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) + avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) @@ -372,6 +397,11 @@ extension ProfileHeaderView { assert(sender === relationshipActionButton) delegate?.profileHeaderView(self, relationshipButtonDidPressed: relationshipActionButton) } + + @objc private func avatarImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) + } } // MARK: - ActiveLabelDelegate diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index e4be1eb1f..ff8c38edf 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -10,7 +10,7 @@ import UIKit import Combine import ActiveLabel -final class ProfileViewController: UIViewController, NeedsDependency { +final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -18,6 +18,8 @@ final class ProfileViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: ProfileViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() + private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) barButtonItem.tintColor = .white @@ -645,6 +647,38 @@ extension ProfileViewController: ProfilePagingViewControllerDelegate { // MARK: - ProfileHeaderViewDelegate extension ProfileViewController: ProfileHeaderViewDelegate { + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) { + guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let avatar = imageView.image else { return } + + let meta = MediaPreviewViewModel.ProfileAvatarImagePreviewMeta( + accountObjectID: mastodonUser.objectID, + preloadThumbnailImage: avatar + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .profileAvatar(profileHeaderView), + previewableViewController: self + ) + pushTransitionItem.aspectRatio = CGSize(width: 100, height: 100) + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.sourceImageViewCornerRadius = ProfileHeaderView.avatarImageViewCornerRadius + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = avatar + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 066783305..1c54fadf0 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -58,10 +58,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { // set to image hidden toVC.pagingViewConttroller.view.alpha = 0 // set from image hidden. update hidden when paging. seealso: `MediaPreviewViewController` - switch transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageView(alpha: 0, index: toVC.viewModel.currentPage.value) - } + transitionItem.source.updateAppearance(position: .start, index: toVC.viewModel.currentPage.value) // Set transition image view assert(transitionItem.initialFrame != nil) @@ -143,16 +140,14 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { } else { fromView.alpha = 0 } + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil } animator.addCompletion { position in self.transitionItem.snapshotTransitioning?.removeFromSuperview() - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + self.transitionItem.source.updateAppearance(position: position, index: nil) transitionContext.completeTransition(position == .end) } @@ -222,6 +217,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { animator.addAnimations { fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil + self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } } animator.addCompletion { position in @@ -231,10 +227,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { self.transitionItem.snapshotRaw?.alpha = position == .start ? 1.0 : 0.0 self.transitionItem.snapshotTransitioning?.removeFromSuperview() if position == .end { - switch self.transitionItem.source { - case .mosaic(let mosaicImageViewContainer): - mosaicImageViewContainer.setImageViews(alpha: 1) - } + // reset appearance + self.transitionItem.source.updateAppearance(position: position, index: nil) } fromVC.visualEffectView.effect = position == .end ? nil : blurEffect transitionContext.completeTransition(position == .end) diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 73afeeb8f..48533cf38 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -20,6 +20,7 @@ class MediaPreviewTransitionItem: Identifiable { var aspectRatio: CGSize? var initialFrame: CGRect? = nil var sourceImageView: UIImageView? + var sourceImageViewCornerRadius: CGFloat? // target var targetFrame: CGRect? = nil @@ -41,5 +42,20 @@ class MediaPreviewTransitionItem: Identifiable { extension MediaPreviewTransitionItem { enum Source { case mosaic(MosaicImageViewContainer) + case profileAvatar(ProfileHeaderView) + + func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { + let alpha: CGFloat = position == .end ? 1 : 0 + switch self { + case .mosaic(let mosaicImageViewContainer): + if let index = index { + mosaicImageViewContainer.setImageView(alpha: 0, index: index) + } else { + mosaicImageViewContainer.setImageViews(alpha: alpha) + } + case .profileAvatar(let profileHeaderView): + profileHeaderView.avatarImageView.alpha = alpha + } + } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index e5eb8ba6c..c7080b217 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -19,6 +19,8 @@ extension MediaPreviewableViewController { guard index < mosaicImageViewContainer.imageViews.count else { return nil } let imageView = mosaicImageViewContainer.imageViews[index] return imageView.superview!.convert(imageView.frame, to: nil) + case .profileAvatar(let profileHeaderView): + return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) } } } From 7a13b39c5111470220a76401e89a30af01821fb8 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 28 Apr 2021 20:36:10 +0800 Subject: [PATCH 345/400] feat: handle profile banner preview --- .../Protocol/AvatarConfigurableView.swift | 2 +- .../MediaPreviewViewController.swift | 2 +- .../MediaPreview/MediaPreviewViewModel.swift | 26 ++++++++++++++++ .../Header/View/ProfileHeaderView.swift | 12 +++++++ .../Scene/Profile/ProfileViewController.swift | 31 +++++++++++++++++++ ...wViewControllerAnimatedTransitioning.swift | 21 ++++++++++--- .../MediaPreviewTransitionItem.swift | 3 ++ .../MediaPreviewableViewController.swift | 2 ++ 8 files changed, 93 insertions(+), 6 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index f2e954910..3d2dba802 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -164,7 +164,7 @@ struct AvatarConfigurableViewConfiguration { placeholderImage: UIImage? = nil, borderColor: UIColor? = nil, borderWidth: CGFloat? = nil, - keepImageCorner: Bool = true + keepImageCorner: Bool = false // default clip corner on image ) { self.avatarImageURL = avatarImageURL self.placeholderImage = placeholderImage diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 01d33853a..6feaaff49 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -106,7 +106,7 @@ extension MediaPreviewViewController { mosaicImageViewContainer.setImageViews(alpha: 1) mosaicImageViewContainer.setImageView(alpha: 0, index: index) } - case .profileAvatar: + case .profileAvatar, .profileBanner: break } } diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 6a0b749b6..1b0cc6def 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -52,6 +52,26 @@ final class MediaPreviewViewModel: NSObject { super.init() } + init(context: AppContext, meta: ProfileBannerImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { + self.context = context + self.initialItem = .profileBanner(meta) + var viewControllers: [UIViewController] = [] + let managedObjectContext = self.context.managedObjectContext + managedObjectContext.performAndWait { + let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser + let avatarURL = account.headerImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) + let mediaPreviewImageViewController = MediaPreviewImageViewController() + mediaPreviewImageViewController.viewModel = mediaPreviewImageModel + viewControllers.append(mediaPreviewImageViewController) + } + self.viewControllers = viewControllers + self.currentPage = CurrentValueSubject(0) + self.pushTransitionItem = pushTransitionItem + super.init() + } + init(context: AppContext, meta: ProfileAvatarImagePreviewMeta, pushTransitionItem: MediaPreviewTransitionItem) { self.context = context self.initialItem = .profileAvatar(meta) @@ -79,6 +99,7 @@ extension MediaPreviewViewModel { enum PreviewItem { case status(StatusImagePreviewMeta) case profileAvatar(ProfileAvatarImagePreviewMeta) + case profileBanner(ProfileBannerImagePreviewMeta) case local(LocalImagePreviewMeta) } @@ -93,6 +114,11 @@ extension MediaPreviewViewModel { let preloadThumbnailImage: UIImage? } + struct ProfileBannerImagePreviewMeta { + let accountObjectID: NSManagedObjectID + let preloadThumbnailImage: UIImage? + } + struct LocalImagePreviewMeta { let image: UIImage } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 1f1f6b711..d5fcc5c47 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -12,6 +12,7 @@ import TwitterTextEditor protocol ProfileHeaderViewDelegate: AnyObject { func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarImageViewDidPressed imageView: UIImageView) + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) func profileHeaderView(_ profileHeaderView: ProfileHeaderView, activeLabel: ActiveLabel, entityDidPressed entity: ActiveEntity) @@ -43,6 +44,7 @@ final class ProfileHeaderView: UIView { imageView.image = .placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor imageView.layer.masksToBounds = true + imageView.isUserInteractionEnabled = true // #if DEBUG // imageView.image = .placeholder(color: .red) // #endif @@ -338,6 +340,11 @@ extension ProfileHeaderView { let avatarImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer avatarImageView.addGestureRecognizer(avatarImageViewSingleTapGestureRecognizer) avatarImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.avatarImageViewDidPressed(_:))) + + let bannerImageViewSingleTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + bannerImageView.addGestureRecognizer(bannerImageViewSingleTapGestureRecognizer) + bannerImageViewSingleTapGestureRecognizer.addTarget(self, action: #selector(ProfileHeaderView.bannerImageViewDidPressed(_:))) + relationshipActionButton.addTarget(self, action: #selector(ProfileHeaderView.relationshipActionButtonDidPressed(_:)), for: .touchUpInside) configure(state: .normal) @@ -402,6 +409,11 @@ extension ProfileHeaderView { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) delegate?.profileHeaderView(self, avatarImageViewDidPressed: avatarImageView) } + + @objc private func bannerImageViewDidPressed(_ sender: UITapGestureRecognizer) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.profileHeaderView(self, bannerImageViewDidPressed: bannerImageView) + } } // MARK: - ActiveLabelDelegate diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index ff8c38edf..7df3bc235 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -679,6 +679,37 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + guard let mastodonUser = viewModel.mastodonUser.value else { return } + guard let header = imageView.image else { return } + + let meta = MediaPreviewViewModel.ProfileBannerImagePreviewMeta( + accountObjectID: mastodonUser.objectID, + preloadThumbnailImage: header + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .profileBanner(profileHeaderView), + previewableViewController: self + ) + pushTransitionItem.aspectRatio = header.size + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + let initialFrame = imageView.superview!.convert(imageView.frame, to: nil) + assert(initialFrame != .zero) + return initialFrame + }() + pushTransitionItem.image = header + + let mediaPreviewViewModel = MediaPreviewViewModel( + context: context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: self.mediaPreviewTransitionController)) + } + } + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) { let relationshipActionSet = viewModel.relationshipActionOptionSet.value if relationshipActionSet.contains(.edit) { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 1c54fadf0..a0bf523f9 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -204,7 +204,7 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { transitionItem.imageView = imageView transitionItem.snapshotTransitioning = snapshot transitionItem.initialFrame = snapshot.frame - transitionItem.targetFrame = targetFrame + transitionItem.targetFrame = targetFrame ?? snapshot.frame // disable interaction fromVC.pagingViewConttroller.isUserInteractionEnabled = false @@ -215,6 +215,12 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { self.transitionItem.snapshotRaw?.alpha = 0.0 animator.addAnimations { + switch self.transitionItem.source { + case .profileBanner: + self.transitionItem.snapshotTransitioning?.alpha = 0.4 + default: + break + } fromVC.closeButtonBackground.alpha = 0 fromVC.visualEffectView.effect = nil self.transitionItem.sourceImageViewCornerRadius.flatMap { self.transitionItem.snapshotTransitioning?.layer.cornerRadius = $0 } @@ -310,11 +316,18 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { itemAnimator.addAnimations { if toPosition == .end { - if let targetFrame = self.transitionItem.targetFrame { - self.transitionItem.snapshotTransitioning?.frame = targetFrame - } else { + switch self.transitionItem.source { + case .profileBanner where toPosition == .end: + // fade transition for banner self.transitionItem.snapshotTransitioning?.alpha = 0 + default: + if let targetFrame = self.transitionItem.targetFrame { + self.transitionItem.snapshotTransitioning?.frame = targetFrame + } else { + self.transitionItem.snapshotTransitioning?.alpha = 0 + } } + } else { if let initialFrame = self.transitionItem.initialFrame { self.transitionItem.snapshotTransitioning?.frame = initialFrame diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 48533cf38..47fdd215d 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -43,6 +43,7 @@ extension MediaPreviewTransitionItem { enum Source { case mosaic(MosaicImageViewContainer) case profileAvatar(ProfileHeaderView) + case profileBanner(ProfileHeaderView) func updateAppearance(position: UIViewAnimatingPosition, index: Int?) { let alpha: CGFloat = position == .end ? 1 : 0 @@ -55,6 +56,8 @@ extension MediaPreviewTransitionItem { } case .profileAvatar(let profileHeaderView): profileHeaderView.avatarImageView.alpha = alpha + case .profileBanner: + break // keep source } } } diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index c7080b217..8029c09da 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -21,6 +21,8 @@ extension MediaPreviewableViewController { return imageView.superview!.convert(imageView.frame, to: nil) case .profileAvatar(let profileHeaderView): return profileHeaderView.avatarImageView.superview!.convert(profileHeaderView.avatarImageView.frame, to: nil) + case .profileBanner: + return nil // fallback to snapshot.frame } } } From 236b5ca0dc83c18afd0f849e5acaafdadfa6d5af Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 10:50:10 +0800 Subject: [PATCH 346/400] fix: add observer for more menu --- Mastodon/Diffiable/Section/StatusSection.swift | 14 +++++++++++++- Mastodon/Extension/CoreDataStack/Status.swift | 7 +++++++ Mastodon/Protocol/UserProvider/UserProvider.swift | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 36eecdbe5..0e26e1479 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -628,6 +628,18 @@ extension StatusSection { cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike + ManagedObjectObserver.observe(object: status.authorForUserProvider) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak dependency, weak cell] change in + guard let cell = cell else { return } + guard let dependency = dependency else { return } + if case .update( _) = change.changeType { + StatusSection.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) + } + } + .store(in: &cell.disposeBag) self.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) } @@ -767,7 +779,7 @@ extension StatusSection { guard let authenticationBox = dependency.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - let author = (status.reblog ?? status).author + let author = status.authorForUserProvider let canReport = authenticationBox.userID != author.id let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 880be6fa3..39b421dbc 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -63,3 +63,10 @@ extension Status { } } + +extension Status { + var authorForUserProvider: MastodonUser { + let author = (reblog ?? self).author + return author + } +} diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift index 7426d89ac..f3ee36c32 100644 --- a/Mastodon/Protocol/UserProvider/UserProvider.swift +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -23,7 +23,7 @@ extension UserProvider where Self: StatusProvider { guard let self = self else { return } self.status(for: cell, indexPath: indexPath) .sink { status in - promise(.success(status?.author)) + promise(.success(status?.authorForUserProvider)) } .store(in: &self.disposeBag) } From 211e2f25d5f7b95bd2e5c8a8a67ec0d2d80cbfef Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 11:03:21 +0800 Subject: [PATCH 347/400] chore: add share post --- Localization/app.json | 1 + Mastodon/Extension/CoreDataStack/Status.swift | 13 ++++++++++ Mastodon/Generated/Strings.swift | 2 ++ .../UserProvider/UserProviderFacade.swift | 25 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 1 + 5 files changed, 42 insertions(+) diff --git a/Localization/app.json b/Localization/app.json index 96f366933..35cdda165 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -51,6 +51,7 @@ "preview": "Preview", "share": "Share", "share_user": "Share %s", + "share_post": "Share post", "open_in_safari": "Open in Safari", "find_people": "Find people to follow", "manually_search": "Manually search instead", diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 39b421dbc..015671cb5 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -70,3 +70,16 @@ extension Status { return author } } + +extension Status { + + var statusURL: URL { + return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! + } + + var activityItems: [Any] { + var items: [Any] = [] + items.append(statusURL) + return items + } +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 8dd327048..c0d1cac6f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -96,6 +96,8 @@ internal enum L10n { internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") /// Share internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") + /// Share post + internal static let sharePost = L10n.tr("Localizable", "Common.Controls.Actions.SharePost") /// Share %@ internal static func shareUser(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Actions.ShareUser", String(describing: p1)) diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 0d839bd17..cbde09034 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -264,6 +264,23 @@ extension UserProviderFacade { children.append(shareAction) } + if let shareStatus = shareStatus { + let shareAction = UIAction(title: L10n.Common.Controls.Actions.sharePost, image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let activityViewController = createActivityViewControllerForMastodonUser(status: shareStatus, dependency: provider) + provider.coordinator.present( + scene: .activityViewController( + activityViewController: activityViewController, + sourceView: sourceView, + barButtonItem: barButtonItem + ), + from: provider, + transition: .activityViewControllerPresent(animated: true, completion: nil) + ) + } + children.append(shareAction) + } + return UIMenu(title: "", options: [], children: children) } @@ -274,5 +291,13 @@ extension UserProviderFacade { ) return activityViewController } + + static func createActivityViewControllerForMastodonUser(status: Status, dependency: NeedsDependency) -> UIActivityViewController { + let activityViewController = UIActivityViewController( + activityItems: status.activityItems, + applicationActivities: [SafariActivity(sceneCoordinator: dependency.coordinator)] + ) + return activityViewController + } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index da8bca1c9..a2f653d81 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -31,6 +31,7 @@ Please check your internet connection."; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; "Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.SharePost" = "Share post"; "Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; "Common.Controls.Actions.SignUp" = "Sign Up"; From 2fd10eab8433f46b74f5aa72cb9cce8f29da6166 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 16:03:44 +0800 Subject: [PATCH 348/400] fix: avatar not using inner border issue --- .../Scene/Profile/Header/View/ProfileHeaderView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index d5fcc5c47..1e09116d3 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -206,10 +206,10 @@ extension ProfileHeaderView { avatarImageViewBackgroundView.translatesAutoresizingMaskIntoConstraints = false bannerContainerView.insertSubview(avatarImageViewBackgroundView, belowSubview: avatarImageView) NSLayoutConstraint.activate([ - avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), - avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.topAnchor.constraint(equalTo: avatarImageViewBackgroundView.topAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageView.leadingAnchor.constraint(equalTo: avatarImageViewBackgroundView.leadingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), + avatarImageViewBackgroundView.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 0.5 * ProfileHeaderView.avatarImageViewBorderWidth), ]) editAvatarBackgroundView.translatesAutoresizingMaskIntoConstraints = false From 9768721247437a9780b6c6e944cce70fc0373c56 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 16:20:18 +0800 Subject: [PATCH 349/400] fix: the Core Data thread-safe issue --- .../APIService/APIService+Notification.swift | 16 ++++++++-------- .../API/Mastodon+API+Account+FollowRequest.swift | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Mastodon/Service/APIService/APIService+Notification.swift b/Mastodon/Service/APIService/APIService+Notification.swift index a27aae2ae..6e8af70bc 100644 --- a/Mastodon/Service/APIService/APIService+Notification.swift +++ b/Mastodon/Service/APIService/APIService+Notification.swift @@ -28,15 +28,15 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in let log = OSLog.api - if query.maxID == nil { - let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest - requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) - let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) - oldNotifications.forEach { notification in - self.backgroundManagedObjectContext.delete(notification) - } - } return self.backgroundManagedObjectContext.performChanges { + if query.maxID == nil { + let requestMastodonNotificationRequest = MastodonNotification.sortedFetchRequest + requestMastodonNotificationRequest.predicate = MastodonNotification.predicate(domain: domain, userID: userID) + let oldNotifications = self.backgroundManagedObjectContext.safeFetch(requestMastodonNotificationRequest) + oldNotifications.forEach { notification in + self.backgroundManagedObjectContext.delete(notification) + } + } response.value.forEach { notification in let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.backgroundManagedObjectContext, for: nil, in: domain, entity: notification.account, userCache: nil, networkDate: Date(), log: log) var status: Status? diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index f08e888b5..004197143 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -11,13 +11,13 @@ import Combine // MARK: - Account credentials extension Mastodon.API.Account { - static func acceptFollowRequestEndpointURL(domain: String, userID: String) -> URL { + static func acceptFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("authorize") } - static func rejectFollowRequestEndpointURL(domain: String, userID: String) -> URL { + static func rejectFollowRequestEndpointURL(domain: String, userID: Mastodon.Entity.Account.ID) -> URL { return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("follow_requests") .appendingPathComponent(userID) .appendingPathComponent("reject") @@ -34,12 +34,12 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database - /// - authorization: App token + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func acceptFollowRequest( session: URLSession, domain: String, - userID: String, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( @@ -66,12 +66,12 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - userID: ID of the account in the database - /// - authorization: App token + /// - authorization: User token /// - Returns: `AnyPublisher` contains `Relationship` nested in the response public static func rejectFollowRequest( session: URLSession, domain: String, - userID: String, + userID: Mastodon.Entity.Account.ID, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { let request = Mastodon.API.post( From ccdc48add110a8cf90d54d3d9b6d063376aca541 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 15:51:52 +0800 Subject: [PATCH 350/400] feature: blockDomain --- .../CoreData.xcdatamodel/contents | 17 ++- CoreDataStack/Entity/DomainBlock.swift | 73 ++++++++++ CoreDataStack/Entity/MastodonUser.swift | 3 +- CoreDataStack/Entity/Status.swift | 2 +- Localization/app.json | 7 +- Mastodon.xcodeproj/project.pbxproj | 12 ++ .../Diffiable/Section/StatusSection.swift | 3 +- .../CoreDataStack/MastodonUser.swift | 9 ++ Mastodon/Extension/CoreDataStack/Status.swift | 7 +- Mastodon/Generated/Strings.swift | 12 ++ .../UserProvider/UserProviderFacade.swift | 18 +++ .../Resources/en.lproj/Localizable.strings | 3 + .../Scene/Profile/ProfileViewController.swift | 3 + .../APIService/APIService+DomainBlock.swift | 129 +++++++++++++++++ Mastodon/Service/APIService/APIService.swift | 1 + Mastodon/Service/BlockDomainService.swift | 22 +++ .../API/Mastodon+API+DomainBlock.swift | 136 ++++++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 9 ++ 18 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 CoreDataStack/Entity/DomainBlock.swift create mode 100644 Mastodon/Service/APIService/APIService+DomainBlock.swift create mode 100644 Mastodon/Service/BlockDomainService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 0d0170282..1738e3310 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -24,6 +24,18 @@ + + + + + + + + + + + + @@ -252,6 +264,7 @@ + @@ -269,4 +282,4 @@ - + \ No newline at end of file diff --git a/CoreDataStack/Entity/DomainBlock.swift b/CoreDataStack/Entity/DomainBlock.swift new file mode 100644 index 000000000..71fa7d461 --- /dev/null +++ b/CoreDataStack/Entity/DomainBlock.swift @@ -0,0 +1,73 @@ +// +// DomainBlock.swift +// CoreDataStack +// +// Created by sxiaojian on 2021/4/29. +// + +import CoreData +import Foundation + +public final class DomainBlock: NSManagedObject { + @NSManaged public private(set) var blockedDomain: String + @NSManaged public private(set) var createAt: Date + + @NSManaged public private(set) var domain: String + @NSManaged public private(set) var userID: String + + override public func awakeFromInsert() { + super.awakeFromInsert() + setPrimitiveValue(Date(), forKey: #keyPath(DomainBlock.createAt)) + } +} + +extension DomainBlock { + @discardableResult + public static func insert( + into context: NSManagedObjectContext, + blockedDomain: String, + domain: String, + userID: String + ) -> DomainBlock { + let domainBlock: DomainBlock = context.insertObject() + domainBlock.domain = domain + domainBlock.blockedDomain = blockedDomain + domainBlock.userID = userID + return domainBlock + } +} + +extension DomainBlock: Managed { + public static var defaultSortDescriptors: [NSSortDescriptor] { + [NSSortDescriptor(keyPath: \DomainBlock.createAt, ascending: false)] + } +} + +extension DomainBlock { + static func predicate(domain: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.domain), domain) + } + + static func predicate(userID: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.userID), userID) + } + + static func predicate(blockedDomain: String) -> NSPredicate { + NSPredicate(format: "%K == %@", #keyPath(DomainBlock.blockedDomain), blockedDomain) + } + + public static func predicate(domain: String, userID: String) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + DomainBlock.predicate(domain: domain), + DomainBlock.predicate(userID: userID) + ]) + } + + public static func predicate(domain: String, userID: String, blockedDomain: String) -> NSPredicate { + NSCompoundPredicate(andPredicateWithSubpredicates: [ + DomainBlock.predicate(domain: domain), + DomainBlock.predicate(userID: userID), + DomainBlock.predicate(blockedDomain:blockedDomain) + ]) + } +} diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 714b6d0f6..c094bf4f6 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -333,7 +333,7 @@ extension MastodonUser: Managed { extension MastodonUser { - static func predicate(domain: String) -> NSPredicate { + public static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) } @@ -369,5 +369,4 @@ extension MastodonUser { MastodonUser.predicate(username: username) ]) } - } diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index 1bb71a1db..73c704046 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -310,7 +310,7 @@ extension Status: Managed { extension Status { - static func predicate(domain: String) -> NSPredicate { + public static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) } diff --git a/Localization/app.json b/Localization/app.json index 35cdda165..80a9e2dd6 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -27,6 +27,10 @@ "title": "Sign out", "message": "Are you sure you want to sign out?", "confirm": "Sign Out" + }, + "block_domain": { + "message": "Are you really, really sure you want to block the entire %s ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", + "block_entire_domain": "Block entire domain" } }, "controls": { @@ -56,7 +60,8 @@ "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", - "report_user": "Report %s" + "report_user": "Report %s", + "block_domain": "Block %s" }, "status": { "user_reblogged": "%s reblogged", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 99ca696f1..65a7447ee 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -117,6 +117,9 @@ 2D939AB525EDD8A90076FA61 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AB425EDD8A90076FA61 /* String.swift */; }; 2D939AC825EE14620076FA61 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = 2D939AC725EE14620076FA61 /* CropViewController */; }; 2D939AE825EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */; }; + 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */; }; + 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB968263A833E007C1D71 /* DomainBlock.swift */; }; + 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */; }; 2DA504692601ADE7008F4E6C /* SawToothView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA504682601ADE7008F4E6C /* SawToothView.swift */; }; 2DA6054725F716A2006356F9 /* PlaybackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6054625F716A2006356F9 /* PlaybackState.swift */; }; 2DA6055125F74407006356F9 /* AudioContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */; }; @@ -543,6 +546,9 @@ 2D927F1325C7EDD9004F19B8 /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 2D939AB425EDD8A90076FA61 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 2D939AE725EE1CF80076FA61 /* MastodonRegisterViewController+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonRegisterViewController+Avatar.swift"; sourceTree = ""; }; + 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockDomainService.swift; sourceTree = ""; }; + 2D9DB968263A833E007C1D71 /* DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainBlock.swift; sourceTree = ""; }; + 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+DomainBlock.swift"; sourceTree = ""; }; 2DA504682601ADE7008F4E6C /* SawToothView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SawToothView.swift; sourceTree = ""; }; 2DA6054625F716A2006356F9 /* PlaybackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackState.swift; sourceTree = ""; }; 2DA6055025F74407006356F9 /* AudioContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContainerViewModel.swift; sourceTree = ""; }; @@ -1091,6 +1097,7 @@ 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, DB71FD4B25F8C80E00512AE1 /* StatusPrefetchingService.swift */, DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, + 2D9DB966263A76FB007C1D71 /* BlockDomainService.swift */, ); path = Service; sourceTree = ""; @@ -1494,6 +1501,7 @@ DB98336A25C9420100AD9700 /* APIService+App.swift */, DB98337025C9443200AD9700 /* APIService+Authentication.swift */, DB98339B25C96DE600AD9700 /* APIService+Account.swift */, + 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */, 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, @@ -1683,6 +1691,7 @@ isa = PBXGroup; children = ( DB89BA2625C110B4008580ED /* Status.swift */, + 2D9DB968263A833E007C1D71 /* DomainBlock.swift */, 2D6125462625436B00299647 /* Notification.swift */, 2D0B7A1C261D839600B44727 /* SearchHistory.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, @@ -2405,6 +2414,7 @@ DB6B35182601FA3400DC1E11 /* MastodonAttachmentService.swift in Sources */, 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, + 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, @@ -2535,6 +2545,7 @@ 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, 2D38F1DF25CD46A400561493 /* HomeTimelineViewController+Provider.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, + 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, 2D46975E25C2A54100CF4AA9 /* NSLayoutConstraint.swift in Sources */, 2D45E5BF25C9549700A6D639 /* PublicTimelineViewModel+State.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, @@ -2752,6 +2763,7 @@ 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */, + 2D9DB969263A833E007C1D71 /* DomainBlock.swift in Sources */, 2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, 5B90C46E26259B2C0002E742 /* Subscription.swift in Sources */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 0e26e1479..839682c4a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -781,7 +781,7 @@ extension StatusSection { } let author = status.authorForUserProvider let canReport = authenticationBox.userID != author.id - + let canBlockDomain = authenticationBox.domain != author.domain let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) @@ -791,6 +791,7 @@ extension StatusSection { isMuting: isMuting, isBlocking: isBlocking, canReport: canReport, + canBlockDomain: canBlockDomain, provider: userProvider, cell: cell, indexPath: indexPath, diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index bb99b15d4..96f95e5e2 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -50,6 +50,15 @@ extension MastodonUser { } } + var domainFromAcct: String { + if !acct.contains("@") { + return domain + } else { + let domain = acct.split(separator: "@").last + return String(domain!) + } + } + } extension MastodonUser { diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 015671cb5..5eec9f4aa 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -74,7 +74,12 @@ extension Status { extension Status { var statusURL: URL { - return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! + if let urlString = self.url, + let url = URL(string: urlString) { + return url + } else { + return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! + } } var activityItems: [Any] { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index c0d1cac6f..18de7e8e5 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -13,6 +13,14 @@ internal enum L10n { internal enum Common { internal enum Alerts { + internal enum BlockDomain { + /// Block entire domain + internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain") + /// Are you really, really sure you want to block the entire %@ ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed. + internal static func message(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Message", String(describing: p1)) + } + } internal enum Common { /// Please try again. internal static let pleaseTryAgain = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgain") @@ -60,6 +68,10 @@ internal enum L10n { internal static let add = L10n.tr("Localizable", "Common.Controls.Actions.Add") /// Back internal static let back = L10n.tr("Localizable", "Common.Controls.Actions.Back") + /// Block %@ + internal static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.BlockDomain", String(describing: p1)) + } /// Cancel internal static let cancel = L10n.tr("Localizable", "Common.Controls.Actions.Cancel") /// Confirm diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index cbde09034..a4356e71a 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -159,6 +159,7 @@ extension UserProviderFacade { isMuting: Bool, isBlocking: Bool, canReport: Bool, + canBlockDomain: Bool, provider: UserProvider, cell: UITableViewCell?, indexPath: IndexPath?, @@ -247,6 +248,23 @@ extension UserProviderFacade { children.append(reportAction) } + if canBlockDomain { + let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domain), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domain), preferredStyle: .alert) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + + } + alertController.addAction(cancelAction) + let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in + BlockDomainService(context: provider.context).blockDomain(domain: mastodonUser.domain) + } + alertController.addAction(blockDomainAction) + provider.present(alertController, animated: true, completion: nil) + } + children.append(blockDomainAction) + } + if let shareUser = shareUser { let shareAction = UIAction(title: L10n.Common.Controls.Actions.shareUser(name), image: UIImage(systemName: "square.and.arrow.up"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index a2f653d81..983de7327 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,3 +1,5 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; +"Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@ ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; @@ -14,6 +16,7 @@ Please check your internet connection."; "Common.Alerts.VoteFailure.Title" = "Vote Failure"; "Common.Controls.Actions.Add" = "Add"; "Common.Controls.Actions.Back" = "Back"; +"Common.Controls.Actions.BlockDomain" = "Block %@"; "Common.Controls.Actions.Cancel" = "Cancel"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 7a8742f18..21571faa7 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -374,14 +374,17 @@ extension ProfileViewController { self.moreMenuBarButtonItem.menu = nil return } + guard let currentDomain = self.viewModel.domain.value else { return } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value + let canBlockDomain = mastodonUser.domain != currentDomain self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, canReport: true, + canBlockDomain: canBlockDomain, provider: self, cell: nil, indexPath: nil, diff --git a/Mastodon/Service/APIService/APIService+DomainBlock.swift b/Mastodon/Service/APIService/APIService+DomainBlock.swift new file mode 100644 index 000000000..bb54d2983 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+DomainBlock.swift @@ -0,0 +1,129 @@ +// +// APIService+DomainBlock.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/29. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import DateToolsSwift +import MastodonSDK + +extension APIService { + + func getDomainblocks( + domain: String, + limit: Int = onceRequestDomainBlocksMaxCount, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + let query = Mastodon.API.DomainBlock.Query( + maxID: nil, sinceID: nil, limit: limit + ) + return Mastodon.API.DomainBlock.getDomainblocks( + domain: domain, + session: session, + authorization: authorization, + query: query + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + response.value.forEach { domain in + // use constrain to avoid repeated save + let _ = DomainBlock.insert( + into: self.backgroundManagedObjectContext, + blockedDomain: domain, + domain: authorizationBox.domain, + userID: authorizationBox.userID + ) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content<[String]> in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func blockDomain( + domain: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + return Mastodon.API.DomainBlock.blockDomain( + domain: authorizationBox.domain, + blockDomain: domain, + session: session, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { + let _ = DomainBlock.insert( + into: self.backgroundManagedObjectContext, + blockedDomain: domain, + domain: authorizationBox.domain, + userID: authorizationBox.userID + ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func unblockDomain( + domain: String, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + + return Mastodon.API.DomainBlock.unblockDomain( + domain: authorizationBox.domain, + blockDomain: domain, + session: session, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges { +// let _ = DomainBlock.insert( +// into: self.backgroundManagedObjectContext, +// blockedDomain: domain, +// domain: authorizationBox.domain, +// userID: authorizationBox.userID +// ) + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } +} diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index 11e6f5cac..b38e2e059 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -46,6 +46,7 @@ final class APIService { extension APIService { public static let onceRequestStatusMaxCount = 100 public static let onceRequestUserMaxCount = 100 + public static let onceRequestDomainBlocksMaxCount = 100 } extension APIService { diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift new file mode 100644 index 000000000..91d226989 --- /dev/null +++ b/Mastodon/Service/BlockDomainService.swift @@ -0,0 +1,22 @@ +// +// BlockDomainService.swift +// Mastodon +// +// Created by sxiaojian on 2021/4/29. +// + +import CoreData +import CoreDataStack +import Foundation + +final class BlockDomainService { + let context: AppContext + + init(context: AppContext) { + self.context = context + } + + func blockDomain(domain: String) { + + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift new file mode 100644 index 000000000..f0ee51d90 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift @@ -0,0 +1,136 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/4/29. +// + +import Foundation +import Combine + +extension Mastodon.API.DomainBlock { + static func domainBlockEndpointURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain).appendingPathComponent("domain_blocks") + } + + /// Fetch domain blocks + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func getDomainblocks( + domain: String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization, + query: Mastodon.API.DomainBlock.Query + ) -> AnyPublisher, Error> { + let url = domainBlockEndpointURL(domain: domain) + let request = Mastodon.API.get(url: url, query: query, authorization: authorization) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [String].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Block a domain + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func blockDomain( + domain: String, + blockDomain:String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) + let request = Mastodon.API.post( + url: domainBlockEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + /// Unblock a domain + /// + /// - Since: 1.4.0 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/accounts/domain_blocks/) + /// - Parameters: + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - session: `URLSession` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `String` nested in the response + public static func unblockDomain( + domain: String, + blockDomain:String, + session: URLSession, + authorization: Mastodon.API.OAuth.Authorization + ) -> AnyPublisher, Error> { + let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) + let request = Mastodon.API.delete( + url: domainBlockEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } +} + +extension Mastodon.API.DomainBlock { + public struct Query: GetQuery { + public let maxID: Mastodon.Entity.Status.ID? + public let sinceID: Mastodon.Entity.Status.ID? + public let limit: Int? + + public init( + maxID: Mastodon.Entity.Status.ID?, + sinceID: Mastodon.Entity.Status.ID?, + limit: Int? + ) { + self.maxID = maxID + self.sinceID = sinceID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + sinceID.flatMap { items.append(URLQueryItem(name: "since_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } + + public struct BlockQuery: Codable, PostQuery { + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index cfaa1736d..79408f833 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -117,6 +117,7 @@ extension Mastodon.API { public enum Notifications { } public enum Subscriptions { } public enum Reports { } + public enum DomainBlock { } } extension Mastodon.API.V2 { @@ -141,6 +142,14 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .POST, query: query, authorization: authorization) } + + static func delete( + url: URL, + query: PostQuery?, + authorization: OAuth.Authorization? + ) -> URLRequest { + return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) + } static func patch( url: URL, From 40e62a8a439c407d166167741913734f276bd817 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 17:02:46 +0800 Subject: [PATCH 351/400] fix: change version of followRequest --- .../MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift index 004197143..87c879ea0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+FollowRequest.swift @@ -27,7 +27,7 @@ extension Mastodon.API.Account { /// /// /// - Since: 0.0.0 - /// - Version: 3.0.0 + /// - Version: 3.3.0 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) /// - Parameters: @@ -59,7 +59,7 @@ extension Mastodon.API.Account { /// /// /// - Since: 0.0.0 - /// - Version: 3.0.0 + /// - Version: 3.3.0 /// # Reference /// [Document](https://docs.joinmastodon.org/methods/accounts/follow_requests/) /// - Parameters: From 4b96ac4481a292476bbacfbae0dbcb52fbe5c9e5 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 17:05:35 +0800 Subject: [PATCH 352/400] fix: disallow header preview when editing profile --- Mastodon/Scene/Profile/ProfileViewController.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 7df3bc235..17d0f6b87 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -680,6 +680,9 @@ extension ProfileViewController: ProfileHeaderViewDelegate { } func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { + // not preview header banner when editing + guard !viewModel.isEditing.value else { return } + guard let mastodonUser = viewModel.mastodonUser.value else { return } guard let header = imageView.image else { return } From df2a73d96c9ad4480620b9a9374a725b3ea56172 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 17:13:13 +0800 Subject: [PATCH 353/400] fix: profile title view not align center issue. resolve #117. --- .../Profile/Header/ProfileHeaderViewController.swift | 5 ++++- .../Scene/Profile/Header/ProfileHeaderViewModel.swift | 1 + Mastodon/Scene/Profile/ProfileViewController.swift | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index bb89dab07..3949c3281 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -328,7 +328,9 @@ extension ProfileHeaderViewController { let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset - titleView.containerView.transform = CGAffineTransform(translationX: 0, y: max(0, titleViewContentOffset)) + let transformY = max(0, titleViewContentOffset) + titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) + viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height if viewModel.viewDidAppear.value { viewModel.isTitleViewContentOffsetSet.value = true @@ -347,6 +349,7 @@ extension ProfileHeaderViewController { } private func setProfileBannerFade(alpha: CGFloat) { + profileHeaderView.avatarImageViewBackgroundView.alpha = alpha profileHeaderView.avatarImageView.alpha = alpha profileHeaderView.editAvatarBackgroundView.alpha = alpha profileHeaderView.nameTextFieldBackgroundView.alpha = alpha diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index eb4a054b8..6e4fe2def 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -24,6 +24,7 @@ final class ProfileHeaderViewModel { // output let displayProfileInfo = ProfileInfo() let editProfileInfo = ProfileInfo() + let isTitleViewDisplaying = CurrentValueSubject(false) init(context: AppContext) { self.context = context diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 17d0f6b87..d826cdaaa 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -172,13 +172,14 @@ extension ProfileViewController { } .store(in: &disposeBag) - Publishers.CombineLatest3 ( + Publishers.CombineLatest4 ( viewModel.suspended.eraseToAnyPublisher(), + profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), editingAndUpdatingPublisher.eraseToAnyPublisher(), barButtonItemHiddenPublisher.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) - .sink { [weak self] suspended, tuple1, tuple2 in + .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in guard let self = self else { return } let (isEditing, _) = tuple1 let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 @@ -197,6 +198,10 @@ extension ProfileViewController { return } + guard !isTitleViewDisplaying else { + return + } + guard isMeBarButtonItemsHidden else { items.append(self.settingBarButtonItem) items.append(self.shareBarButtonItem) From 1e5daf5a7714c4a85aa8e4807fa3dfb26ebcda3f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 16:12:04 +0800 Subject: [PATCH 354/400] fix: the race-condition issue in username checking --- .../Register/MastodonRegisterViewModel.swift | 56 +++++++++++-------- .../MastodonServerRulesViewController.swift | 2 +- .../API/Mastodon+API+Account.swift | 2 +- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 919443f2d..5fd8c31b6 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -82,10 +82,36 @@ final class MastodonRegisterViewModel { .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) - username.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates() - .sink { [weak self] text in - self?.lookupAccount(by: text) + username + .filter { !$0.isEmpty } + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .compactMap { [weak self] text -> AnyPublisher, Error>, Never>? in + guard let self = self else { return nil } + let query = Mastodon.API.Account.AccountLookupQuery(acct: text) + return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) + .map { + response -> Result, Error>in + Result.success(response) + } + .catch { error in + Just(Result.failure(error)) + } + .eraseToAnyPublisher() } + .switchToLatest() + .sink(receiveCompletion: { _ in + + }, receiveValue: { [weak self] result in + guard let self = self else { return } + switch result { + case .success: + let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) + self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) + case .failure: + break + } + }) .store(in: &disposeBag) usernameValidateState @@ -133,7 +159,8 @@ final class MastodonRegisterViewModel { let error = error as? Mastodon.API.Error let mastodonError = error?.mastodonError if case let .generic(genericMastodonError) = mastodonError, - let details = genericMastodonError.details { + let details = genericMastodonError.details + { self.usernameErrorPrompt.value = details.usernameErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.emailErrorPrompt.value = details.emailErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } self.passwordErrorPrompt.value = details.passwordErrorDescriptions.first.flatMap { MastodonRegisterViewModel.errorPromptAttributedString(for: $0) } @@ -157,29 +184,12 @@ final class MastodonRegisterViewModel { Publishers.CombineLatest( publisherOne, - approvalRequired ? reasonValidateState.map {$0 == .valid}.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() + approvalRequired ? reasonValidateState.map { $0 == .valid }.eraseToAnyPublisher() : Just(true).eraseToAnyPublisher() ) .map { $0 && $1 } .assign(to: \.value, on: isAllValid) .store(in: &disposeBag) } - - func lookupAccount(by acct: String) { - if acct.isEmpty { - return - } - let query = Mastodon.API.Account.AccountLookupQuery(acct: acct) - context.apiService.accountLookup(domain: domain, query: query, authorization: applicationAuthorization) - .sink { _ in - - } receiveValue: { [weak self] account in - guard let self = self else { return } - let text = L10n.Scene.Register.Error.Reason.taken(L10n.Scene.Register.Error.Item.username) - self.usernameErrorPrompt.value = MastodonRegisterViewModel.errorPromptAttributedString(for: text) - } - .store(in: &disposeBag) - - } } extension MastodonRegisterViewModel { @@ -191,7 +201,6 @@ extension MastodonRegisterViewModel { } extension MastodonRegisterViewModel { - static func isValidEmail(_ email: String) -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" @@ -241,5 +250,4 @@ extension MastodonRegisterViewModel { return attributeString } - } diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index 447896c22..d8638421a 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -204,7 +204,7 @@ extension MastodonServerRulesViewController { @objc private func confirmButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain,context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) + let viewModel = MastodonRegisterViewModel(domain: self.viewModel.domain, context: self.context, authenticateInfo: self.viewModel.authenticateInfo, instance: self.viewModel.instance, applicationToken: self.viewModel.applicationToken) self.coordinator.present(scene: .mastodonRegister(viewModel: viewModel), from: self, transition: .show) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift index 78f338b6f..d1c5458c4 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account.swift @@ -161,7 +161,7 @@ extension Mastodon.API.Account { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `AccountInfoQuery` with account query information, - /// - authorization: user token + /// - authorization: app token /// - Returns: `AnyPublisher` contains `Account` nested in the response public static func lookupAccount( session: URLSession, From ca320c555aa22d7322ffd35304c85ddbf7d32f91 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 29 Apr 2021 19:24:03 +0800 Subject: [PATCH 355/400] fix: code format --- .../Onboarding/Register/MastodonRegisterViewModel.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 5fd8c31b6..309204a9a 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -91,7 +91,7 @@ final class MastodonRegisterViewModel { let query = Mastodon.API.Account.AccountLookupQuery(acct: text) return context.apiService.accountLookup(domain: domain, query: query, authorization: self.applicationAuthorization) .map { - response -> Result, Error>in + response -> Result, Error> in Result.success(response) } .catch { error in @@ -100,9 +100,7 @@ final class MastodonRegisterViewModel { .eraseToAnyPublisher() } .switchToLatest() - .sink(receiveCompletion: { _ in - - }, receiveValue: { [weak self] result in + .sink { [weak self] result in guard let self = self else { return } switch result { case .success: @@ -111,7 +109,7 @@ final class MastodonRegisterViewModel { case .failure: break } - }) + } .store(in: &disposeBag) usernameValidateState From aace886401c2dfc5b73ff4962abf943d1f515974 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 29 Apr 2021 19:49:46 +0800 Subject: [PATCH 356/400] feat: add save photo action for image preview scene --- Mastodon.xcodeproj/project.pbxproj | 4 ++ .../xcschemes/xcschememanagement.plist | 4 +- .../MediaPreviewViewController.swift | 33 ++++++++- .../MediaPreviewImageViewController.swift | 53 +++++++++++++- .../Image/MediaPreviewImageViewModel.swift | 13 ++++ Mastodon/Service/PhotoLibraryService.swift | 69 +++++++++++++++++++ Mastodon/State/AppContext.swift | 3 +- 7 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 Mastodon/Service/PhotoLibraryService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 80ce2d0ea..428a99162 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -380,6 +380,7 @@ DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */; }; DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; @@ -935,6 +936,7 @@ DB9E0D6E25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIInterpolatingMotionEffect.swift; sourceTree = ""; }; DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1289,6 +1291,7 @@ DBC7A67B260DFADE00E57475 /* StatusPublishService.swift */, DB4924E126312AB200E9DB22 /* NotificationService.swift */, DB6D9F6226357848008423CD /* SettingService.swift */, + DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, ); path = Service; sourceTree = ""; @@ -2864,6 +2867,7 @@ 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, DB6180F226391CF40018D199 /* MediaPreviewImageViewModel.swift in Sources */, + DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */, 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DBAE3F882615DDF4004B8251 /* UserProviderFacade.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 326857269..8ec70bb4e 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 18 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 15 + 17 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 6feaaff49..b57be0fb1 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -19,7 +19,7 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { var disposeBag = Set() var viewModel: MediaPreviewViewModel! - + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let pagingViewConttroller = MediaPreviewPagingViewController() @@ -191,11 +191,38 @@ extension MediaPreviewViewController: PageboyViewControllerDelegate { extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) { - + // do nothing } func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) { - // delegate?.mediaPreviewViewController(self, longPressGestureRecognizerTriggered: longPressGestureRecognizer) + // do nothing + } + + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) { + switch action { + case .savePhoto: + switch viewController.viewModel.item { + case .status(let meta): + context.photoLibraryService.saveImage(url: meta.url).sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &context.disposeBag) + case .local(let meta): + context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) + } + case .share: + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: viewController.viewModel.item.activityItems, + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = viewController.previewImageView.imageView + self.present(activityViewController, animated: true, completion: nil) + } } } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index f44e1de8f..ac4a4c96d 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -9,9 +9,10 @@ import os.log import UIKit import Combine -protocol MediaPreviewImageViewControllerDelegate: class { +protocol MediaPreviewImageViewControllerDelegate: AnyObject { func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, tapGestureRecognizerDidTrigger tapGestureRecognizer: UITapGestureRecognizer) func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, longPressGestureRecognizerDidTrigger longPressGestureRecognizer: UILongPressGestureRecognizer) + func mediaPreviewImageViewController(_ viewController: MediaPreviewImageViewController, contextMenuActionPerform action: MediaPreviewImageViewController.ContextMenuAction) } final class MediaPreviewImageViewController: UIViewController { @@ -63,6 +64,9 @@ extension MediaPreviewImageViewController { previewImageView.addGestureRecognizer(tapGestureRecognizer) previewImageView.addGestureRecognizer(longPressGestureRecognizer) + let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) + previewImageView.addInteraction(previewImageViewContextMenuInteraction) + switch viewModel.item { case .status(let meta): // progressBarView.isHidden = meta.thumbnail != nil @@ -113,3 +117,50 @@ extension MediaPreviewImageViewController { } } + +// MARK: - UIContextMenuInteractionDelegate +extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + + let previewProvider: UIContextMenuContentPreviewProvider = { () -> UIViewController? in + return nil + } + + let saveAction = UIAction( + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) + } + + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) + } + + let actionProvider: UIContextMenuActionProvider = { elements -> UIMenu? in + return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ + saveAction, + shareAction + ]) + } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider) + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + // set preview view + return UITargetedPreview(view: previewImageView.imageView) + } + +} + +extension MediaPreviewImageViewController { + enum ContextMenuAction { + case savePhoto + case share + } +} diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 0ba7d4dc8..9215ef61f 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -27,6 +27,19 @@ extension MediaPreviewImageViewModel { enum ImagePreviewItem { case status(RemoteImagePreviewMeta) case local(LocalImagePreviewMeta) + + var activityItems: [Any] { + var items: [Any] = [] + + switch self { + case .status(let meta): + items.append(meta.url) + case .local(let meta): + items.append(meta.image) + } + + return items + } } struct RemoteImagePreviewMeta { diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift new file mode 100644 index 000000000..44918eb53 --- /dev/null +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -0,0 +1,69 @@ +// +// PhotoLibraryService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-29. +// + +import os.log +import UIKit +import Combine +import AlamofireImage + +final class PhotoLibraryService: NSObject { + +} + +extension PhotoLibraryService { + + func saveImage(url: URL) -> AnyPublisher { + let impactFeedbackGenerator = UIImpactFeedbackGenerator(style: .light) + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + + return Future { promise in + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + promise(.failure(error)) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.save(image: image) + promise(.success(image)) + } + }) + } + .handleEvents(receiveSubscription: { _ in + impactFeedbackGenerator.impactOccurred() + }, receiveCompletion: { completion in + switch completion { + case .failure: + notificationFeedbackGenerator.notificationOccurred(.error) + case .finished: + notificationFeedbackGenerator.notificationOccurred(.success) + } + }) + .eraseToAnyPublisher() + } + + func save(image: UIImage, withNotificationFeedback: Bool = false) { + UIImageWriteToSavedPhotosAlbum( + image, + self, + #selector(PhotoLibraryService.image(_:didFinishSavingWithError:contextInfo:)), + nil + ) + + // assert no error + if withNotificationFeedback { + let notificationFeedbackGenerator = UINotificationFeedbackGenerator() + notificationFeedbackGenerator.notificationOccurred(.success) + } + } + + @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + // TODO: notify banner + } + +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 93287f6eb..7a74de8bd 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -30,7 +30,8 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService - + let photoLibraryService = PhotoLibraryService() + let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! From 33401b4e1f2e4eb35f67c6ad8590522db5e550c9 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 30 Apr 2021 12:53:25 +0800 Subject: [PATCH 357/400] feature: finish domainBlock action and domainUnblick action --- .../CoreData.xcdatamodel/contents | 3 +- Localization/app.json | 5 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Diffiable/Section/StatusSection.swift | 49 +++++++--- Mastodon/Generated/Strings.swift | 10 +- .../UserProvider/UserProviderFacade.swift | 43 ++++++--- .../Resources/en.lproj/Localizable.strings | 3 +- .../Scene/Profile/ProfileViewController.swift | 6 +- Mastodon/Scene/Profile/ProfileViewModel.swift | 4 + .../APIService/APIService+DomainBlock.swift | 68 +++++++++----- Mastodon/Service/BlockDomainService.swift | 92 +++++++++++++++++-- .../API/Mastodon+API+DomainBlock.swift | 8 +- .../Entity/Mastodon+Entity+Empty.swift | 15 +++ 13 files changed, 234 insertions(+), 74 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 1738e3310..7945a97af 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -26,6 +26,7 @@ + @@ -264,7 +265,7 @@ - + diff --git a/Localization/app.json b/Localization/app.json index 80a9e2dd6..5139cfa64 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -61,7 +61,8 @@ "manually_search": "Manually search instead", "skip": "Skip", "report_user": "Report %s", - "block_domain": "Block %s" + "block_domain": "Block %s", + "unblock_domain": "Unblock %s" }, "status": { "user_reblogged": "%s reblogged", @@ -91,7 +92,7 @@ "pending": "Pending", "block": "Block", "block_user": "Block %s", - "block_domain": "Block %s", + "block_domain": "Domain Blocked", "unblock": "Unblock", "unblock_user": "Unblock %s", "blocked": "Blocked", diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 741947371..4c5c26898 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -51,7 +51,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 839682c4a..30d3bde4c 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -781,24 +781,43 @@ extension StatusSection { } let author = status.authorForUserProvider let canReport = authenticationBox.userID != author.id - let canBlockDomain = authenticationBox.domain != author.domain + let isInSameDomain = authenticationBox.domain == author.domainFromAcct let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true - cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( - for: author, - isMuting: isMuting, - isBlocking: isBlocking, - canReport: canReport, - canBlockDomain: canBlockDomain, - provider: userProvider, - cell: cell, - indexPath: indexPath, - sourceView: cell.statusView.actionToolbarContainer.moreButton, - barButtonItem: nil, - shareUser: nil, - shareStatus: status - ) + let managedObjectContext = userProvider.context.backgroundManagedObjectContext + managedObjectContext.perform { + let blockedDomain: DomainBlock? = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authenticationBox.domain, userID: authenticationBox.userID, blockedDomain: author.domainFromAcct) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try managedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + let isDomainBlocking = blockedDomain != nil + DispatchQueue.main.async { + cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( + for: author, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: canReport, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, + provider: userProvider, + cell: cell, + indexPath: indexPath, + sourceView: cell.statusView.actionToolbarContainer.moreButton, + barButtonItem: nil, + shareUser: nil, + shareStatus: status + ) + } + } } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 18de7e8e5..26816a32f 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -124,14 +124,16 @@ internal enum L10n { internal static let takePhoto = L10n.tr("Localizable", "Common.Controls.Actions.TakePhoto") /// Try Again internal static let tryAgain = L10n.tr("Localizable", "Common.Controls.Actions.TryAgain") + /// Unblock %@ + internal static func unblockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Actions.UnblockDomain", String(describing: p1)) + } } internal enum Firendship { /// Block internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") - /// Block %@ - internal static func blockDomain(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) - } + /// Domain Blocked + internal static let blockDomain = L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain") /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") /// Block %@ diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index a4356e71a..d1615212b 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -159,7 +159,8 @@ extension UserProviderFacade { isMuting: Bool, isBlocking: Bool, canReport: Bool, - canBlockDomain: Bool, + isInSameDomain: Bool, + isDomainBlocking: Bool, provider: UserProvider, cell: UITableViewCell?, indexPath: IndexPath?, @@ -248,21 +249,37 @@ extension UserProviderFacade { children.append(reportAction) } - if canBlockDomain { - let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domain), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in - guard let provider = provider else { return } - let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domain), preferredStyle: .alert) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + if !isInSameDomain { + if isDomainBlocking { + let unblockDomainAction = UIAction(title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + BlockDomainService(userProvider: provider, + cell: cell, + indexPath: indexPath + ) + .unblockDomain() + } + children.append(unblockDomainAction) + } else { + let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in + guard let provider = provider else { return } + let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domainFromAcct), preferredStyle: .alert) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + } + alertController.addAction(cancelAction) + let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in + BlockDomainService(userProvider: provider, + cell: cell, + indexPath: indexPath + ) + .blockDomain() + } + alertController.addAction(blockDomainAction) + provider.present(alertController, animated: true, completion: nil) } - alertController.addAction(cancelAction) - let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in - BlockDomainService(context: provider.context).blockDomain(domain: mastodonUser.domain) - } - alertController.addAction(blockDomainAction) - provider.present(alertController, animated: true, completion: nil) + children.append(blockDomainAction) } - children.append(blockDomainAction) } if let shareUser = shareUser { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 983de7327..08c23a305 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -41,8 +41,9 @@ Please check your internet connection."; "Common.Controls.Actions.Skip" = "Skip"; "Common.Controls.Actions.TakePhoto" = "Take photo"; "Common.Controls.Actions.TryAgain" = "Try Again"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Firendship.Block" = "Block"; -"Common.Controls.Firendship.BlockDomain" = "Block %@"; +"Common.Controls.Firendship.BlockDomain" = "Domain Blocked"; "Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 21571faa7..317d51c03 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -377,14 +377,16 @@ extension ProfileViewController { guard let currentDomain = self.viewModel.domain.value else { return } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) + let isDomainBlocking = relationshipActionOptionSet.contains(.domainBlocking) let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value - let canBlockDomain = mastodonUser.domain != currentDomain + let isInSameDomain = mastodonUser.domainFromAcct == currentDomain self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( for: mastodonUser, isMuting: isMuting, isBlocking: isBlocking, canReport: true, - canBlockDomain: canBlockDomain, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, provider: self, cell: nil, indexPath: nil, diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index 445952e96..c19ae2f63 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -327,6 +327,7 @@ extension ProfileViewModel { case muting case blocked case blocking + case domainBlocking case suspended case edit case editing @@ -349,6 +350,7 @@ extension ProfileViewModel { static let muting = RelationshipAction.muting.option static let blocked = RelationshipAction.blocked.option static let blocking = RelationshipAction.blocking.option + static let domainBlocking = RelationshipAction.domainBlocking.option static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -379,6 +381,7 @@ extension ProfileViewModel { case .muting: return L10n.Common.Controls.Firendship.muted case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocking: return L10n.Common.Controls.Firendship.blocked + case .domainBlocking: return L10n.Common.Controls.Firendship.blockDomain case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done @@ -400,6 +403,7 @@ extension ProfileViewModel { case .muting: return Asset.Colors.Background.alertYellow.color case .blocked: return Asset.Colors.Button.normal.color case .blocking: return Asset.Colors.Background.danger.color + case .domainBlocking: return Asset.Colors.Button.normal.color case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color diff --git a/Mastodon/Service/APIService/APIService+DomainBlock.swift b/Mastodon/Service/APIService/APIService+DomainBlock.swift index bb54d2983..7d9936c5a 100644 --- a/Mastodon/Service/APIService/APIService+DomainBlock.swift +++ b/Mastodon/Service/APIService/APIService+DomainBlock.swift @@ -5,16 +5,15 @@ // Created by sxiaojian on 2021/4/29. // -import Foundation import Combine +import CommonOSLog import CoreData import CoreDataStack -import CommonOSLog import DateToolsSwift +import Foundation import MastodonSDK extension APIService { - func getDomainblocks( domain: String, limit: Int = onceRequestDomainBlocksMaxCount, @@ -32,10 +31,10 @@ extension APIService { query: query ) .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { + self.backgroundManagedObjectContext.performChanges { response.value.forEach { domain in // use constrain to avoid repeated save - let _ = DomainBlock.insert( + _ = DomainBlock.insert( into: self.backgroundManagedObjectContext, blockedDomain: domain, domain: authorizationBox.domain, @@ -58,28 +57,33 @@ extension APIService { } func blockDomain( - domain: String, + user: MastodonUser, authorizationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = authorizationBox.userAuthorization return Mastodon.API.DomainBlock.blockDomain( domain: authorizationBox.domain, - blockDomain: domain, + blockDomain: user.domainFromAcct, session: session, authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { - let _ = DomainBlock.insert( + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + _ = DomainBlock.insert( into: self.backgroundManagedObjectContext, - blockedDomain: domain, + blockedDomain: user.domainFromAcct, domain: authorizationBox.domain, userID: authorizationBox.userID ) + user.update(isDomainBlocking: true, by: requestMastodonUser) } .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in + .tryMap { result -> Mastodon.Response.Content in switch result { case .success: return response @@ -93,28 +97,42 @@ extension APIService { } func unblockDomain( - domain: String, + user: MastodonUser, authorizationBox: AuthenticationService.MastodonAuthenticationBox - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let authorization = authorizationBox.userAuthorization return Mastodon.API.DomainBlock.unblockDomain( domain: authorizationBox.domain, - blockDomain: domain, + blockDomain: user.domainFromAcct, session: session, authorization: authorization ) - .flatMap { response -> AnyPublisher, Error> in - return self.backgroundManagedObjectContext.performChanges { -// let _ = DomainBlock.insert( -// into: self.backgroundManagedObjectContext, -// blockedDomain: domain, -// domain: authorizationBox.domain, -// userID: authorizationBox.userID -// ) + .flatMap { response -> AnyPublisher, Error> in + self.backgroundManagedObjectContext.performChanges { + let blockedDomain: DomainBlock? = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID, blockedDomain: user.domainFromAcct) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let blockedDomain = blockedDomain { + self.backgroundManagedObjectContext.delete(blockedDomain) + } + let requestMastodonUserRequest = MastodonUser.sortedFetchRequest + requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) + requestMastodonUserRequest.fetchLimit = 1 + guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } + user.update(isDomainBlocking: false, by: requestMastodonUser) } .setFailureType(to: Error.self) - .tryMap { result -> Mastodon.Response.Content in + .tryMap { result -> Mastodon.Response.Content in switch result { case .success: return response diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 91d226989..0cc35f0d2 100644 --- a/Mastodon/Service/BlockDomainService.swift +++ b/Mastodon/Service/BlockDomainService.swift @@ -8,15 +8,95 @@ import CoreData import CoreDataStack import Foundation +import Combine +import MastodonSDK +import OSLog +import UIKit final class BlockDomainService { - let context: AppContext - - init(context: AppContext) { - self.context = context + let userProvider: UserProvider + let cell: UITableViewCell? + let indexPath: IndexPath? + init(userProvider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? + ) { + self.userProvider = userProvider + self.cell = cell + self.indexPath = indexPath } - func blockDomain(domain: String) { - + func blockDomain() { + guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = self.userProvider.context else { + return + } + var mastodonUser: AnyPublisher + if let cell = self.cell, let indexPath = self.indexPath { + mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + } else { + mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() + } + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) + } + .switchToLatest() + .flatMap { response -> AnyPublisher, Error> in + return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + } + .sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + print(error) + } + } receiveValue: { response in + print(response) + } + .store(in: &userProvider.disposeBag) + } + + func unblockDomain() { + guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = self.userProvider.context else { + return + } + var mastodonUser: AnyPublisher + if let cell = self.cell, let indexPath = self.indexPath { + mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + } else { + mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() + } + mastodonUser + .compactMap { mastodonUser -> AnyPublisher, Error>? in + guard let mastodonUser = mastodonUser else { + return nil + } + return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) + } + .switchToLatest() + .flatMap { response -> AnyPublisher, Error> in + return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + } + .sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + print(error) + } + } receiveValue: { response in + print(response) + } + .store(in: &userProvider.disposeBag) + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift index f0ee51d90..356ac68a0 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift @@ -54,7 +54,7 @@ extension Mastodon.API.DomainBlock { blockDomain:String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) let request = Mastodon.API.post( url: domainBlockEndpointURL(domain: domain), @@ -63,7 +63,7 @@ extension Mastodon.API.DomainBlock { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() @@ -84,7 +84,7 @@ extension Mastodon.API.DomainBlock { blockDomain:String, session: URLSession, authorization: Mastodon.API.OAuth.Authorization - ) -> AnyPublisher, Error> { + ) -> AnyPublisher, Error> { let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) let request = Mastodon.API.delete( url: domainBlockEndpointURL(domain: domain), @@ -93,7 +93,7 @@ extension Mastodon.API.DomainBlock { ) return session.dataTaskPublisher(for: request) .tryMap { data, response in - let value = try Mastodon.API.decode(type: String.self, from: data, response: response) + let value = try Mastodon.API.decode(type: Mastodon.Entity.Empty.self, from: data, response: response) return Mastodon.Response.Content(value: value, response: response) } .eraseToAnyPublisher() diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift new file mode 100644 index 000000000..93ee8ed37 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift @@ -0,0 +1,15 @@ +// +// File.swift +// +// +// Created by sxiaojian on 2021/4/30. +// + +import Foundation + +extension Mastodon.Entity { + public struct Empty: Codable { + + } + +} From 8a5c62990e9447bbda7e650fdff2ee15f8bfbb25 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 30 Apr 2021 14:55:02 +0800 Subject: [PATCH 358/400] chore: add BlockDomainService --- CoreDataStack/Entity/DomainBlock.swift | 6 +- CoreDataStack/Entity/MastodonUser.swift | 3 +- CoreDataStack/Entity/Status.swift | 2 +- .../Diffiable/Section/StatusSection.swift | 74 +++++++--------- Mastodon/Extension/CoreDataStack/Status.swift | 10 +-- .../Protocol/UserProvider/UserProvider.swift | 10 +-- .../UserProvider/UserProviderFacade.swift | 28 ++----- .../Scene/Profile/ProfileViewController.swift | 62 +++++++------- .../APIService/APIService+DomainBlock.swift | 34 +++----- Mastodon/Service/BlockDomainService.swift | 84 ++++++++++++------- Mastodon/State/AppContext.swift | 6 ++ .../API/Mastodon+API+DomainBlock.swift | 12 ++- .../MastodonSDK/API/Mastodon+API.swift | 8 -- .../Entity/Mastodon+Entity+Empty.swift | 1 - .../Sources/MastodonSDK/Query/Query.swift | 5 ++ 15 files changed, 170 insertions(+), 175 deletions(-) diff --git a/CoreDataStack/Entity/DomainBlock.swift b/CoreDataStack/Entity/DomainBlock.swift index 71fa7d461..3dd244c75 100644 --- a/CoreDataStack/Entity/DomainBlock.swift +++ b/CoreDataStack/Entity/DomainBlock.swift @@ -51,7 +51,7 @@ extension DomainBlock { static func predicate(userID: String) -> NSPredicate { NSPredicate(format: "%K == %@", #keyPath(DomainBlock.userID), userID) } - + static func predicate(blockedDomain: String) -> NSPredicate { NSPredicate(format: "%K == %@", #keyPath(DomainBlock.blockedDomain), blockedDomain) } @@ -62,12 +62,12 @@ extension DomainBlock { DomainBlock.predicate(userID: userID) ]) } - + public static func predicate(domain: String, userID: String, blockedDomain: String) -> NSPredicate { NSCompoundPredicate(andPredicateWithSubpredicates: [ DomainBlock.predicate(domain: domain), DomainBlock.predicate(userID: userID), - DomainBlock.predicate(blockedDomain:blockedDomain) + DomainBlock.predicate(blockedDomain: blockedDomain) ]) } } diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index c094bf4f6..714b6d0f6 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -333,7 +333,7 @@ extension MastodonUser: Managed { extension MastodonUser { - public static func predicate(domain: String) -> NSPredicate { + static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(MastodonUser.domain), domain) } @@ -369,4 +369,5 @@ extension MastodonUser { MastodonUser.predicate(username: username) ]) } + } diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index 73c704046..1bb71a1db 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -310,7 +310,7 @@ extension Status: Managed { extension Status { - public static func predicate(domain: String) -> NSPredicate { + static func predicate(domain: String) -> NSPredicate { return NSPredicate(format: "%K == %@", #keyPath(Status.domain), domain) } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 30d3bde4c..118b3dfe7 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -628,18 +628,18 @@ extension StatusSection { cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike - ManagedObjectObserver.observe(object: status.authorForUserProvider) - .receive(on: DispatchQueue.main) - .sink { _ in - // do nothing - } receiveValue: { [weak dependency, weak cell] change in - guard let cell = cell else { return } - guard let dependency = dependency else { return } - if case .update( _) = change.changeType { - StatusSection.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) - } - } - .store(in: &cell.disposeBag) + Publishers.CombineLatest( + dependency.context.blockDomainService.blockedDomains, + ManagedObjectObserver.observe(object: status.authorForUserProvider) + .assertNoFailure() + ) + .receive(on: DispatchQueue.main) + .sink { [weak dependency, weak cell] domains,change in + guard let cell = cell else { return } + guard let dependency = dependency else { return } + StatusSection.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) + } + .store(in: &cell.disposeBag) self.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) } @@ -784,40 +784,22 @@ extension StatusSection { let isInSameDomain = authenticationBox.domain == author.domainFromAcct let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) - + let isDomainBlocking = dependency.context.blockDomainService.blockedDomains.value.contains(author.domainFromAcct) cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true - let managedObjectContext = userProvider.context.backgroundManagedObjectContext - managedObjectContext.perform { - let blockedDomain: DomainBlock? = { - let request = DomainBlock.sortedFetchRequest - request.predicate = DomainBlock.predicate(domain: authenticationBox.domain, userID: authenticationBox.userID, blockedDomain: author.domainFromAcct) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try managedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - let isDomainBlocking = blockedDomain != nil - DispatchQueue.main.async { - cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( - for: author, - isMuting: isMuting, - isBlocking: isBlocking, - canReport: canReport, - isInSameDomain: isInSameDomain, - isDomainBlocking: isDomainBlocking, - provider: userProvider, - cell: cell, - indexPath: indexPath, - sourceView: cell.statusView.actionToolbarContainer.moreButton, - barButtonItem: nil, - shareUser: nil, - shareStatus: status - ) - } - } + cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( + for: author, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: canReport, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, + provider: userProvider, + cell: cell, + indexPath: indexPath, + sourceView: cell.statusView.actionToolbarContainer.moreButton, + barButtonItem: nil, + shareUser: nil, + shareStatus: status + ) } } diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 5eec9f4aa..1a909285d 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -5,8 +5,8 @@ // Created by MainasuK Cirno on 2021/2/4. // -import Foundation import CoreDataStack +import Foundation import MastodonSDK extension Status.Property { @@ -34,7 +34,6 @@ extension Status.Property { } extension Status { - enum SensitiveType { case none case all @@ -61,7 +60,6 @@ extension Status { // not sensitive return .none } - } extension Status { @@ -72,10 +70,10 @@ extension Status { } extension Status { - var statusURL: URL { if let urlString = self.url, - let url = URL(string: urlString) { + let url = URL(string: urlString) + { return url } else { return URL(string: "https://\(self.domain)/web/statuses/\(self.id)")! @@ -84,7 +82,7 @@ extension Status { var activityItems: [Any] { var items: [Any] = [] - items.append(statusURL) + items.append(self.statusURL) return items } } diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift index f3ee36c32..58c7ba5f7 100644 --- a/Mastodon/Protocol/UserProvider/UserProvider.swift +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -5,21 +5,21 @@ // Created by MainasuK Cirno on 2021-4-1. // -import UIKit import Combine import CoreData import CoreDataStack +import UIKit protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewController { // async func mastodonUser() -> Future - + func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future } extension UserProvider where Self: StatusProvider { func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { - return Future { [weak self] promise in + Future { [weak self] promise in guard let self = self else { return } self.status(for: cell, indexPath: indexPath) .sink { status in @@ -28,9 +28,9 @@ extension UserProvider where Self: StatusProvider { .store(in: &self.disposeBag) } } - + func mastodonUser() -> Future { - return Future { promise in + Future { promise in promise(.success(nil)) } } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 62cbf4ffc..dccfd4605 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -5,16 +5,15 @@ // Created by MainasuK Cirno on 2021-4-1. // -import UIKit import Combine import CoreData import CoreDataStack import MastodonSDK +import UIKit -enum UserProviderFacade { } +enum UserProviderFacade {} extension UserProviderFacade { - static func toggleUserFollowRelationship( provider: UserProvider ) -> AnyPublisher, Error> { @@ -50,11 +49,9 @@ extension UserProviderFacade { .switchToLatest() .eraseToAnyPublisher() } - } extension UserProviderFacade { - static func toggleUserBlockRelationship( provider: UserProvider, cell: UITableViewCell?, @@ -99,11 +96,9 @@ extension UserProviderFacade { .switchToLatest() .eraseToAnyPublisher() } - } extension UserProviderFacade { - static func toggleUserMuteRelationship( provider: UserProvider, cell: UITableViewCell?, @@ -148,11 +143,9 @@ extension UserProviderFacade { .switchToLatest() .eraseToAnyPublisher() } - } extension UserProviderFacade { - static func createProfileActionMenu( for mastodonUser: MastodonUser, isMuting: Bool, @@ -238,7 +231,8 @@ extension UserProviderFacade { context: provider.context, domain: authenticationBox.domain, user: mastodonUser, - status: nil) + status: nil + ) provider.coordinator.present( scene: .report(viewModel: viewModel), from: provider, @@ -252,11 +246,7 @@ extension UserProviderFacade { if isDomainBlocking { let unblockDomainAction = UIAction(title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } - BlockDomainService(userProvider: provider, - cell: cell, - indexPath: indexPath - ) - .unblockDomain() + provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell, indexPath: indexPath) } children.append(unblockDomainAction) } else { @@ -264,15 +254,10 @@ extension UserProviderFacade { guard let provider = provider else { return } let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domainFromAcct), preferredStyle: .alert) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in - } alertController.addAction(cancelAction) let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in - BlockDomainService(userProvider: provider, - cell: cell, - indexPath: indexPath - ) - .blockDomain() + provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell, indexPath: indexPath) } alertController.addAction(blockDomainAction) provider.present(alertController, animated: true, completion: nil) @@ -333,5 +318,4 @@ extension UserProviderFacade { ) return activityViewController } - } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 1a802712a..b4d52d750 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -366,36 +366,40 @@ extension ProfileViewController { .receive(on: DispatchQueue.main) .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) .store(in: &disposeBag) - viewModel.relationshipActionOptionSet - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionOptionSet in - guard let self = self else { return } - guard let mastodonUser = self.viewModel.mastodonUser.value else { - self.moreMenuBarButtonItem.menu = nil - return - } - guard let currentDomain = self.viewModel.domain.value else { return } - let isMuting = relationshipActionOptionSet.contains(.muting) - let isBlocking = relationshipActionOptionSet.contains(.blocking) - let isDomainBlocking = relationshipActionOptionSet.contains(.domainBlocking) - let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value - let isInSameDomain = mastodonUser.domainFromAcct == currentDomain - self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( - for: mastodonUser, - isMuting: isMuting, - isBlocking: isBlocking, - canReport: true, - isInSameDomain: isInSameDomain, - isDomainBlocking: isDomainBlocking, - provider: self, - cell: nil, - indexPath: nil, - sourceView: nil, - barButtonItem: self.moreMenuBarButtonItem, - shareUser: needsShareAction ? mastodonUser : nil, - shareStatus: nil) + Publishers.CombineLatest( + viewModel.relationshipActionOptionSet, + viewModel.context.blockDomainService.blockedDomains + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] relationshipActionOptionSet,domains in + guard let self = self else { return } + guard let mastodonUser = self.viewModel.mastodonUser.value else { + self.moreMenuBarButtonItem.menu = nil + return } - .store(in: &disposeBag) + guard let currentDomain = self.viewModel.domain.value else { return } + let isMuting = relationshipActionOptionSet.contains(.muting) + let isBlocking = relationshipActionOptionSet.contains(.blocking) + let isDomainBlocking = domains.contains(mastodonUser.domainFromAcct) + let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value + let isInSameDomain = mastodonUser.domainFromAcct == currentDomain + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( + for: mastodonUser, + isMuting: isMuting, + isBlocking: isBlocking, + canReport: true, + isInSameDomain: isInSameDomain, + isDomainBlocking: isDomainBlocking, + provider: self, + cell: nil, + indexPath: nil, + sourceView: nil, + barButtonItem: self.moreMenuBarButtonItem, + shareUser: needsShareAction ? mastodonUser : nil, + shareStatus: nil) + } + .store(in: &disposeBag) + viewModel.isRelationshipActionButtonHidden .receive(on: DispatchQueue.main) .sink { [weak self] isHidden in diff --git a/Mastodon/Service/APIService/APIService+DomainBlock.swift b/Mastodon/Service/APIService/APIService+DomainBlock.swift index 7d9936c5a..887c3f074 100644 --- a/Mastodon/Service/APIService/APIService+DomainBlock.swift +++ b/Mastodon/Service/APIService/APIService+DomainBlock.swift @@ -32,6 +32,19 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in self.backgroundManagedObjectContext.performChanges { + let blockedDomains: [DomainBlock] = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID) + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + blockedDomains.forEach { self.backgroundManagedObjectContext.delete($0) } + response.value.forEach { domain in // use constrain to avoid repeated save _ = DomainBlock.insert( @@ -74,12 +87,6 @@ extension APIService { requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) requestMastodonUserRequest.fetchLimit = 1 guard let requestMastodonUser = self.backgroundManagedObjectContext.safeFetch(requestMastodonUserRequest).first else { return } - _ = DomainBlock.insert( - into: self.backgroundManagedObjectContext, - blockedDomain: user.domainFromAcct, - domain: authorizationBox.domain, - userID: authorizationBox.userID - ) user.update(isDomainBlocking: true, by: requestMastodonUser) } .setFailureType(to: Error.self) @@ -110,21 +117,6 @@ extension APIService { ) .flatMap { response -> AnyPublisher, Error> in self.backgroundManagedObjectContext.performChanges { - let blockedDomain: DomainBlock? = { - let request = DomainBlock.sortedFetchRequest - request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID, blockedDomain: user.domainFromAcct) - request.fetchLimit = 1 - request.returnsObjectsAsFaults = false - do { - return try self.backgroundManagedObjectContext.fetch(request).first - } catch { - assertionFailure(error.localizedDescription) - return nil - } - }() - if let blockedDomain = blockedDomain { - self.backgroundManagedObjectContext.delete(blockedDomain) - } let requestMastodonUserRequest = MastodonUser.sortedFetchRequest requestMastodonUserRequest.predicate = MastodonUser.predicate(domain: authorizationBox.domain, id: authorizationBox.userID) requestMastodonUserRequest.fetchLimit = 1 diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 0cc35f0d2..217f1af1f 100644 --- a/Mastodon/Service/BlockDomainService.swift +++ b/Mastodon/Service/BlockDomainService.swift @@ -5,34 +5,56 @@ // Created by sxiaojian on 2021/4/29. // +import Combine import CoreData import CoreDataStack import Foundation -import Combine import MastodonSDK import OSLog import UIKit final class BlockDomainService { - let userProvider: UserProvider - let cell: UITableViewCell? - let indexPath: IndexPath? - init(userProvider: UserProvider, - cell: UITableViewCell?, - indexPath: IndexPath? + // input + weak var backgroundManagedObjectContext: NSManagedObjectContext? + weak var authenticationService: AuthenticationService? + + // output + let blockedDomains = CurrentValueSubject<[String], Never>([]) + + init( + backgroundManagedObjectContext: NSManagedObjectContext, + authenticationService: AuthenticationService ) { - self.userProvider = userProvider - self.cell = cell - self.indexPath = indexPath + self.backgroundManagedObjectContext = backgroundManagedObjectContext + self.authenticationService = authenticationService + guard let authorizationBox = authenticationService.activeMastodonAuthenticationBox.value else { return } + backgroundManagedObjectContext.perform { + let _blockedDomains: [DomainBlock] = { + let request = DomainBlock.sortedFetchRequest + request.predicate = DomainBlock.predicate(domain: authorizationBox.domain, userID: authorizationBox.userID) + request.returnsObjectsAsFaults = false + do { + return try backgroundManagedObjectContext.fetch(request) + } catch { + assertionFailure(error.localizedDescription) + return [] + } + }() + self.blockedDomains.value = _blockedDomains.map(\.blockedDomain) + } } - func blockDomain() { - guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let context = self.userProvider.context else { + func blockDomain( + userProvider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? + ) { + guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = userProvider.context else { return } var mastodonUser: AnyPublisher - if let cell = self.cell, let indexPath = self.indexPath { + if let cell = cell, let indexPath = indexPath { mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() } else { mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() @@ -45,8 +67,8 @@ final class BlockDomainService { return context.apiService.blockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) } .switchToLatest() - .flatMap { response -> AnyPublisher, Error> in - return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + .flatMap { _ -> AnyPublisher, Error> in + context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) } .sink { completion in switch completion { @@ -55,19 +77,23 @@ final class BlockDomainService { case .failure(let error): print(error) } - } receiveValue: { response in - print(response) + } receiveValue: { [weak self] response in + self?.blockedDomains.value = response.value } .store(in: &userProvider.disposeBag) } - - func unblockDomain() { - guard let activeMastodonAuthenticationBox = self.userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let context = self.userProvider.context else { + + func unblockDomain( + userProvider: UserProvider, + cell: UITableViewCell?, + indexPath: IndexPath? + ) { + guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + guard let context = userProvider.context else { return } var mastodonUser: AnyPublisher - if let cell = self.cell, let indexPath = self.indexPath { + if let cell = cell, let indexPath = indexPath { mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() } else { mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() @@ -80,8 +106,8 @@ final class BlockDomainService { return context.apiService.unblockDomain(user: mastodonUser, authorizationBox: activeMastodonAuthenticationBox) } .switchToLatest() - .flatMap { response -> AnyPublisher, Error> in - return context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) + .flatMap { _ -> AnyPublisher, Error> in + context.apiService.getDomainblocks(domain: activeMastodonAuthenticationBox.domain, authorizationBox: activeMastodonAuthenticationBox) } .sink { completion in switch completion { @@ -90,13 +116,9 @@ final class BlockDomainService { case .failure(let error): print(error) } - } receiveValue: { response in - print(response) + } receiveValue: { [weak self] response in + self?.blockedDomains.value = response.value } .store(in: &userProvider.disposeBag) } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - } } diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index 93287f6eb..962bc7928 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -30,6 +30,7 @@ class AppContext: ObservableObject { let statusPublishService = StatusPublishService() let notificationService: NotificationService let settingService: SettingService + let blockDomainService: BlockDomainService let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! @@ -72,6 +73,11 @@ class AppContext: ObservableObject { notificationService: _notificationService ) + blockDomainService = BlockDomainService( + backgroundManagedObjectContext: _backgroundManagedObjectContext, + authenticationService: _authenticationService + ) + documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange .receive(on: DispatchQueue.main) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift index 356ac68a0..04ed813ab 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+DomainBlock.swift @@ -85,7 +85,7 @@ extension Mastodon.API.DomainBlock { session: URLSession, authorization: Mastodon.API.OAuth.Authorization ) -> AnyPublisher, Error> { - let query = Mastodon.API.DomainBlock.BlockQuery(domain: blockDomain) + let query = Mastodon.API.DomainBlock.BlockDeleteQuery(domain: blockDomain) let request = Mastodon.API.delete( url: domainBlockEndpointURL(domain: domain), query: query, @@ -126,7 +126,17 @@ extension Mastodon.API.DomainBlock { } } + public struct BlockDeleteQuery: Codable, DeleteQuery { + + public let domain: String + + public init(domain: String) { + self.domain = domain + } + } + public struct BlockQuery: Codable, PostQuery { + public let domain: String public init(domain: String) { diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index dcc666472..e202568c5 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -142,14 +142,6 @@ extension Mastodon.API { ) -> URLRequest { return buildRequest(url: url, method: .POST, query: query, authorization: authorization) } - - static func delete( - url: URL, - query: PostQuery?, - authorization: OAuth.Authorization? - ) -> URLRequest { - return buildRequest(url: url, method: .DELETE, query: query, authorization: authorization) - } static func patch( url: URL, diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift index 93ee8ed37..494151178 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Empty.swift @@ -11,5 +11,4 @@ extension Mastodon.Entity { public struct Empty: Codable { } - } diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index b729129bd..7fbe0da9e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -60,3 +60,8 @@ protocol PutQuery: RequestQuery { } // DELETE protocol DeleteQuery: RequestQuery { } + +extension DeleteQuery { + // By default a `PostQuery` does not has query items + var queryItems: [URLQueryItem]? { nil } +} From 514e5b0443c692a214575c615713b7b24a7ccbc5 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 30 Apr 2021 15:38:15 +0800 Subject: [PATCH 359/400] chore: remove useless change --- Localization/app.json | 2 +- Mastodon/Diffiable/Section/StatusSection.swift | 10 +++++++++- Mastodon/Generated/Strings.swift | 6 ++++-- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- Mastodon/Scene/Profile/ProfileViewModel.swift | 4 ---- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 9c2dbef19..85c1b10ea 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -92,7 +92,7 @@ "pending": "Pending", "block": "Block", "block_user": "Block %s", - "block_domain": "Domain Blocked", + "block_domain": "Block %s", "unblock": "Unblock", "unblock_user": "Unblock %s", "blocked": "Blocked", diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 118b3dfe7..f31c2da89 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -634,9 +634,17 @@ extension StatusSection { .assertNoFailure() ) .receive(on: DispatchQueue.main) - .sink { [weak dependency, weak cell] domains,change in + .sink { [weak dependency, weak cell] _,change in guard let cell = cell else { return } guard let dependency = dependency else { return } + switch change.changeType { + case .delete: + return + case .update(_): + break + case .none: + break + } StatusSection.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) } .store(in: &cell.disposeBag) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 7a1d88d6e..b06a0d68d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -132,8 +132,10 @@ internal enum L10n { internal enum Firendship { /// Block internal static let block = L10n.tr("Localizable", "Common.Controls.Firendship.Block") - /// Domain Blocked - internal static let blockDomain = L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain") + /// Block %@ + internal static func blockDomain(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Firendship.BlockDomain", String(describing: p1)) + } /// Blocked internal static let blocked = L10n.tr("Localizable", "Common.Controls.Firendship.Blocked") /// Block %@ diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 234f441bd..e3ed6cf0c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -43,7 +43,7 @@ Please check your internet connection."; "Common.Controls.Actions.TryAgain" = "Try Again"; "Common.Controls.Actions.UnblockDomain" = "Unblock %@"; "Common.Controls.Firendship.Block" = "Block"; -"Common.Controls.Firendship.BlockDomain" = "Domain Blocked"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; "Common.Controls.Firendship.BlockUser" = "Block %@"; "Common.Controls.Firendship.Blocked" = "Blocked"; "Common.Controls.Firendship.EditInfo" = "Edit info"; diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index c19ae2f63..445952e96 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -327,7 +327,6 @@ extension ProfileViewModel { case muting case blocked case blocking - case domainBlocking case suspended case edit case editing @@ -350,7 +349,6 @@ extension ProfileViewModel { static let muting = RelationshipAction.muting.option static let blocked = RelationshipAction.blocked.option static let blocking = RelationshipAction.blocking.option - static let domainBlocking = RelationshipAction.domainBlocking.option static let suspended = RelationshipAction.suspended.option static let edit = RelationshipAction.edit.option static let editing = RelationshipAction.editing.option @@ -381,7 +379,6 @@ extension ProfileViewModel { case .muting: return L10n.Common.Controls.Firendship.muted case .blocked: return L10n.Common.Controls.Firendship.follow // blocked by user case .blocking: return L10n.Common.Controls.Firendship.blocked - case .domainBlocking: return L10n.Common.Controls.Firendship.blockDomain case .suspended: return L10n.Common.Controls.Firendship.follow case .edit: return L10n.Common.Controls.Firendship.editInfo case .editing: return L10n.Common.Controls.Actions.done @@ -403,7 +400,6 @@ extension ProfileViewModel { case .muting: return Asset.Colors.Background.alertYellow.color case .blocked: return Asset.Colors.Button.normal.color case .blocking: return Asset.Colors.Background.danger.color - case .domainBlocking: return Asset.Colors.Button.normal.color case .suspended: return Asset.Colors.Button.normal.color case .edit: return Asset.Colors.Button.normal.color case .editing: return Asset.Colors.Button.normal.color From aceaa618e058329256950212c52a5a3fc1bdea33 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:28:06 +0800 Subject: [PATCH 360/400] feat: add context menu for post image --- Mastodon.xcodeproj/project.pbxproj | 32 +++ .../xcschemes/xcschememanagement.plist | 4 +- .../Diffiable/Section/StatusSection.swift | 4 +- .../StatusProvider+UITableViewDelegate.swift | 250 +++++++++++++++++- .../StatusProvider/StatusProviderFacade.swift | 2 +- .../StatusTableViewControllerAspect.swift | 44 ++- .../HashtagTimelineViewController.swift | 17 ++ .../HomeTimelineViewController.swift | 17 ++ .../MediaPreviewViewController.swift | 18 +- .../MediaPreviewImageViewController.swift | 52 ++-- .../Image/MediaPreviewImageViewModel.swift | 19 ++ .../Favorite/FavoriteViewController.swift | 16 ++ .../Timeline/UserTimelineViewController.swift | 16 ++ ...ontextMenuImagePreviewViewController.swift | 61 +++++ .../ContextMenuImagePreviewViewModel.swift | 25 ++ ...ableViewCellContextMenuConfiguration.swift | 16 ++ .../Container/MosaicImageViewContainer.swift | 24 +- .../Scene/Share/View/Content/StatusView.swift | 4 + .../TableviewCell/StatusTableViewCell.swift | 19 +- .../Scene/Thread/ThreadViewController.swift | 16 ++ ...wViewControllerAnimatedTransitioning.swift | 3 +- .../MediaPreviewTransitionController.swift | 2 +- Mastodon/Vender/CustomScheduler.swift | 50 ++++ 23 files changed, 647 insertions(+), 64 deletions(-) create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift create mode 100644 Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift create mode 100644 Mastodon/Vender/CustomScheduler.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 428a99162..02ff93ccc 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */; }; DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */; }; DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB73B48F261F030A002E9E9F /* SafariActivity.swift */; }; + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */; }; DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */; }; DB789A1225F9F2CC0071ACA0 /* ComposeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */; }; DB789A1C25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */; }; @@ -381,6 +382,9 @@ DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */; }; DBA0A11325FB3FC10079C110 /* ComposeToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */; }; DBA5E7A3263AD0A3004598BB /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */; }; + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */; }; + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */; }; + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */; }; DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */; }; DBAE3F682615DD60004B8251 /* UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F672615DD60004B8251 /* UserProvider.swift */; }; DBAE3F822615DDA3004B8251 /* ProfileViewController+UserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */; }; @@ -869,6 +873,7 @@ DB72601B25E36A2100235243 /* MastodonServerRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewController.swift; sourceTree = ""; }; DB72602625E36A6F00235243 /* MastodonServerRulesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonServerRulesViewModel.swift; sourceTree = ""; }; DB73B48F261F030A002E9E9F /* SafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariActivity.swift; sourceTree = ""; }; + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomScheduler.swift; sourceTree = ""; }; DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewController.swift; sourceTree = ""; }; DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = ""; }; DB789A1B25F9F76A0071ACA0 /* ComposeStatusContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeStatusContentCollectionViewCell.swift; sourceTree = ""; }; @@ -937,6 +942,9 @@ DBA0A10825FB3C2B0079C110 /* RoundedEdgesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedEdgesButton.swift; sourceTree = ""; }; DBA0A11225FB3FC10079C110 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = ""; }; DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewModel.swift; sourceTree = ""; }; + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuImagePreviewViewController.swift; sourceTree = ""; }; + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewCellContextMenuConfiguration.swift; sourceTree = ""; }; DBABE3EB25ECAC4B00879EE5 /* WelcomeIllustrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeIllustrationView.swift; sourceTree = ""; }; DBAE3F672615DD60004B8251 /* UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProvider.swift; sourceTree = ""; }; DBAE3F812615DDA3004B8251 /* ProfileViewController+UserProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewController+UserProvider.swift"; sourceTree = ""; }; @@ -1272,6 +1280,7 @@ DB51D170262832380062B7A1 /* BlurHashDecode.swift */, DB51D171262832380062B7A1 /* BlurHashEncode.swift */, DB6180EC26391C6C0018D199 /* TransitioningMath.swift */, + DB75BF1D263C1C1B00EDBF1F /* CustomScheduler.swift */, ); path = Vender; sourceTree = ""; @@ -1372,6 +1381,7 @@ DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, + DBA5E7A6263BD298004598BB /* ContextMenu */, ); path = Share; sourceTree = ""; @@ -2201,6 +2211,24 @@ path = Helper; sourceTree = ""; }; + DBA5E7A6263BD298004598BB /* ContextMenu */ = { + isa = PBXGroup; + children = ( + DBA5E7A7263BD29F004598BB /* ImagePreview */, + ); + path = ContextMenu; + sourceTree = ""; + }; + DBA5E7A7263BD29F004598BB /* ImagePreview */ = { + isa = PBXGroup; + children = ( + DBA5E7A4263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift */, + DBA5E7A8263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift */, + DBA5E7AA263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift */, + ); + path = ImagePreview; + sourceTree = ""; + }; DBA9B90325F1D4420012E7B6 /* Control */ = { isa = PBXGroup; children = ( @@ -2883,6 +2911,7 @@ 2D42FF8F25C8228A004A627A /* UIButton.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, + DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, 0FAA102725E1126A0017CCDE /* MastodonPickServerViewController.swift in Sources */, DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */, DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */, @@ -2943,6 +2972,7 @@ 5DDDF1A92617489F00311060 /* Mastodon+Entity+History.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DBA5E7AB263BD3F5004598BB /* TimelineTableViewCellContextMenuConfiguration.swift in Sources */, DB73B490261F030A002E9E9F /* SafariActivity.swift in Sources */, DB6D1B44263691CF00ACB481 /* Mastodon+API+Subscriptions+Policy.swift in Sources */, DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, @@ -2957,6 +2987,7 @@ 2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */, 5DF1054725F8870E00D6C0D4 /* VideoPlayerViewModel.swift in Sources */, 2DE0FAC12615F04D00CDF649 /* RecommendHashTagSection.swift in Sources */, + DBA5E7A5263BD28C004598BB /* ContextMenuImagePreviewViewModel.swift in Sources */, 2D38F1E525CD46C100561493 /* HomeTimelineViewModel.swift in Sources */, DBCC3B36261440BA0045B23D /* UINavigationController.swift in Sources */, DBB525852612D6DD002F1F29 /* ProfileStatusDashboardMeterView.swift in Sources */, @@ -3036,6 +3067,7 @@ DB8AF54525C13647002E6C99 /* NeedsDependency.swift in Sources */, DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, + DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DB45FADD25CA6F6B005A8AC7 /* APIService+CoreData+MastodonUser.swift in Sources */, 2D32EABA25CB9B0500C9ED86 /* UIView.swift in Sources */, 2D38F20825CD491300561493 /* DisposeBagCollectable.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 8ec70bb4e..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 18 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 17 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b897de47f..519637b88 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -12,7 +12,7 @@ import os.log import UIKit import AVKit -protocol StatusCell : DisposeBagCollectable { +protocol StatusCell: DisposeBagCollectable { var statusView: StatusView { get } var pollCountdownSubscription: AnyCancellable? { get set } } @@ -142,7 +142,7 @@ extension StatusSection { status: Status, requestUserID: String, statusItemAttribute: Item.StatusAttribute - ) { + ) { // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index cd6cbf589..ef19abab9 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -106,4 +106,252 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } -extension StatusTableViewCellDelegate where Self: StatusProvider {} +extension StatusTableViewCellDelegate where Self: StatusProvider { + + private typealias ImagePreviewPresentableCell = UITableViewCell & DisposeBagCollectable & MosaicImageViewContainerPresentable + + func handleTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil } + guard imagePreviewPresentableCell.isRevealing else { return nil } + + let status = status(for: nil, indexPath: indexPath) + + return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + private func contextMenuConfiguration( + _ tableView: UITableView, + status: Future, + imagePreviewPresentableCell presentable: ImagePreviewPresentableCell, + contextMenuConfigurationForRowAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + let imageViews = presentable.mosaicImageViewContainer.imageViews + guard !imageViews.isEmpty else { return nil } + + for (i, imageView) in imageViews.enumerated() { + let pointInImageView = imageView.convert(point, from: tableView) + guard imageView.point(inside: pointInImageView, with: nil) else { + continue + } + guard let image = imageView.image, image.size != CGSize(width: 1, height: 1) else { + // not provide preview until image ready + return nil + + } + // setup preview + let contextMenuImagePreviewViewModel = ContextMenuImagePreviewViewModel(aspectRatio: image.size, thumbnail: image) + status + .sink { status in + guard let status = (status?.reblog ?? status), + let media = status.mediaAttachments?.sorted(by:{ $0.index.compare($1.index) == .orderedAscending }), + i < media.count, let url = URL(string: media[i].url) else { + return + } + + contextMenuImagePreviewViewModel.url.value = url + } + .store(in: &contextMenuImagePreviewViewModel.disposeBag) + + // setup context menu + let contextMenuConfiguration = TimelineTableViewCellContextMenuConfiguration(identifier: nil) { () -> UIViewController? in + // know issue: preview size looks not as large as system default preview + let previewProvider = ContextMenuImagePreviewViewController() + previewProvider.viewModel = contextMenuImagePreviewViewModel + return previewProvider + } actionProvider: { _ -> UIMenu? in + let savePhotoAction = UIAction( + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.attachment(of: status, index: i) + .setFailureType(to: Error.self) + .compactMap { attachment -> AnyPublisher? in + guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } + return self.context.photoLibraryService.saveImage(url: url) + } + .sink(receiveCompletion: { _ in + // do nothing + }, receiveValue: { _ in + // do nothing + }) + .store(in: &self.context.disposeBag) + } + let shareAction = UIAction( + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) + guard let self = self else { return } + self.attachment(of: status, index: i) + .sink(receiveValue: { [weak self] attachment in + guard let self = self else { return } + guard let attachment = attachment, let url = URL(string: attachment.url) else { return } + let applicationActivities: [UIActivity] = [ + SafariActivity(sceneCoordinator: self.coordinator) + ] + let activityViewController = UIActivityViewController( + activityItems: [url], + applicationActivities: applicationActivities + ) + activityViewController.popoverPresentationController?.sourceView = imageView + self.present(activityViewController, animated: true, completion: nil) + }) + .store(in: &self.context.disposeBag) + } + let children = [savePhotoAction, shareAction] + return UIMenu(title: "", image: nil, children: children) + } + contextMenuConfiguration.indexPath = indexPath + contextMenuConfiguration.index = i + return contextMenuConfiguration + } + + return nil + } + + private func attachment(of status: Future, index: Int) -> AnyPublisher { + status + .map { status in + guard let status = status?.reblog ?? status else { return nil } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + return media[index] + } + .eraseToAnyPublisher() + } + + func handleTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + func handleTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return _handleTableView(tableView, configuration: configuration) + } + + private func _handleTableView(_ tableView: UITableView, configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return nil } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return nil } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { + return nil + } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return nil } + let imageView = imageViews[index] + return UITargetedPreview(view: imageView, parameters: UIPreviewParameters()) + } + + func handleTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + guard let previewableViewController = self as? MediaPreviewableViewController else { return } + guard let configuration = configuration as? TimelineTableViewCellContextMenuConfiguration else { return } + guard let indexPath = configuration.indexPath, let index = configuration.index else { return } + guard let cell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return } + let imageViews = cell.mosaicImageViewContainer.imageViews + guard index < imageViews.count else { return } + let imageView = imageViews[index] + + let status = status(for: nil, indexPath: indexPath) + let initialFrame: CGRect? = { + guard let previewViewController = animator.previewViewController else { return nil } + return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController) + }() + animator.preferredCommitStyle = .pop + animator.addCompletion { [weak self] in + guard let self = self else { return } + status + //.delay(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] status in + guard let self = self else { return } + guard let status = (status?.reblog ?? status) else { return } + + let meta = MediaPreviewViewModel.StatusImagePreviewMeta( + statusObjectID: status.objectID, + initialIndex: index, + preloadThumbnailImages: cell.mosaicImageViewContainer.thumbnails() + ) + let pushTransitionItem = MediaPreviewTransitionItem( + source: .mosaic(cell.mosaicImageViewContainer), + previewableViewController: previewableViewController + ) + pushTransitionItem.aspectRatio = { + if let image = imageView.image { + return image.size + } + guard let media = status.mediaAttachments?.sorted(by: { $0.index.compare($1.index) == .orderedAscending }) else { return nil } + guard index < media.count else { return nil } + let meta = media[index].meta + guard let width = meta?.original?.width, let height = meta?.original?.height else { return nil } + return CGSize(width: width, height: height) + }() + pushTransitionItem.sourceImageView = imageView + pushTransitionItem.initialFrame = { + if let initialFrame = initialFrame { + return initialFrame + } + return imageView.superview!.convert(imageView.frame, to: nil) + }() + pushTransitionItem.image = { + if let image = imageView.image { + return image + } + if index < cell.mosaicImageViewContainer.blurhashOverlayImageViews.count { + return cell.mosaicImageViewContainer.blurhashOverlayImageViews[index].image + } + + return nil + }() + let mediaPreviewViewModel = MediaPreviewViewModel( + context: self.context, + meta: meta, + pushTransitionItem: pushTransitionItem + ) + DispatchQueue.main.async { + self.coordinator.present(scene: .mediaPreview(viewModel: mediaPreviewViewModel), from: self, transition: .custom(transitioningDelegate: previewableViewController.mediaPreviewTransitionController)) + } + } + .store(in: &cell.disposeBag) + } + } + + + + +} + +extension UIView { + + // hack to retrieve preview view frame in window + fileprivate static func findContextMenuPreviewFrameInWindow( + previewController: UIViewController + ) -> CGRect? { + guard let window = previewController.view.window else { return nil } + + let targetViews = window.subviews + .map { $0.findSameSize(view: previewController.view) } + .flatMap { $0 } + for targetView in targetViews { + guard let targetViewSuperview = targetView.superview else { continue } + let frame = targetViewSuperview.convert(targetView.frame, to: nil) + guard frame.origin.x > 0, frame.origin.y > 0 else { continue } + return frame + } + + return nil + } + + private func findSameSize(view: UIView) -> [UIView] { + var views: [UIView] = [] + + if view.bounds.size == bounds.size { + views.append(self) + } + + for subview in subviews { + let targetViews = subview.findSameSize(view: view) + views.append(contentsOf: targetViews) + } + + return views + } + +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift index d53e8e038..56e9d4746 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProviderFacade.swift @@ -540,7 +540,7 @@ extension StatusProviderFacade { let meta = MediaPreviewViewModel.StatusImagePreviewMeta( statusObjectID: status.objectID, initialIndex: index, - preloadThumbnailImages: mosaicImageView.imageViews.map { $0.image } + preloadThumbnailImages: mosaicImageView.thumbnails() ) let pushTransitionItem = MediaPreviewTransitionItem( source: .mosaic(mosaicImageView), diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index f96998ea6..e418569c1 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -9,12 +9,12 @@ import UIKit import AVKit // Check List Last Updated -// - HomeViewController: 2021/4/13 -// - FavoriteViewController: 2021/4/14 -// - HashtagTimelineViewController: 2021/4/8 -// - UserTimelineViewController: 2021/4/13 -// - ThreadViewController: 2021/4/13 -// * StatusTableViewControllerAspect: 2021/4/12 +// - HomeViewController: 2021/4/30 +// - FavoriteViewController: 2021/4/30 +// - HashtagTimelineViewController: 2021/4/30 +// - UserTimelineViewController: 2021/4/30 +// - ThreadViewController: 2021/4/30 +// * StatusTableViewControllerAspect: 2021/4/30 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -103,6 +103,38 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat } } +// [B6] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to display context menu for images + func aspectTableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return handleTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } +} + +// [B7] aspectTableView(_:contextMenuConfigurationForRowAt:point:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } +} + +// [B8] aspectTableView(_:previewForDismissingContextMenuWithConfiguration:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu for images + func aspectTableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return handleTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } +} + +// [B9] aspectTableView(_:willPerformPreviewActionForMenuWith:animator:) +extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegate & StatusProvider { + // [UI] hook to configure context menu preview action + func aspectTableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + handleTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } +} + // MARK: - UITableViewDataSourcePrefetching [C] // [C1] aspectTableView(:prefetchRowsAt) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift index 7a3404732..638aa7665 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController.swift @@ -224,6 +224,23 @@ extension HashtagTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 8932346ed..6db1c26f3 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -378,6 +378,23 @@ extension HomeTimelineViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { aspectTableView(tableView, didSelectRowAt: indexPath) } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index b57be0fb1..5684a6ba4 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -137,9 +137,12 @@ extension MediaPreviewViewController: MediaPreviewingViewController { let safeAreaInsets = previewImageView.safeAreaInsets let statusBarFrameHeight = view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0 - return previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + let dismissable = previewImageView.contentOffset.y <= -(safeAreaInsets.top - statusBarFrameHeight) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable %s", ((#file as NSString).lastPathComponent), #line, #function, dismissable ? "true" : "false") + return dismissable } + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: dismissable false", ((#file as NSString).lastPathComponent), #line, #function) return false } @@ -203,12 +206,13 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { case .savePhoto: switch viewController.viewModel.item { case .status(let meta): - context.photoLibraryService.saveImage(url: meta.url).sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing - } - .store(in: &context.disposeBag) + context.photoLibraryService.saveImage(url: meta.url) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &context.disposeBag) case .local(let meta): context.photoLibraryService.save(image: meta.image, withNotificationFeedback: true) } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index ac4a4c96d..7ac3c2024 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -18,6 +18,8 @@ protocol MediaPreviewImageViewControllerDelegate: AnyObject { final class MediaPreviewImageViewController: UIViewController { var disposeBag = Set() + var observations = Set() + var viewModel: MediaPreviewImageViewModel! weak var delegate: MediaPreviewImageViewControllerDelegate? @@ -56,7 +58,7 @@ extension MediaPreviewImageViewController { previewImageView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), previewImageView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) - + tapGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.tapGestureRecognizerHandler(_:))) longPressGestureRecognizer.addTarget(self, action: #selector(MediaPreviewImageViewController.longPressGestureRecognizerHandler(_:))) tapGestureRecognizer.require(toFail: previewImageView.doubleTapGestureRecognizer) @@ -67,39 +69,15 @@ extension MediaPreviewImageViewController { let previewImageViewContextMenuInteraction = UIContextMenuInteraction(delegate: self) previewImageView.addInteraction(previewImageViewContextMenuInteraction) - switch viewModel.item { - case .status(let meta): -// progressBarView.isHidden = meta.thumbnail != nil - previewImageView.imageView.af.setImage( - withURL: meta.url, - placeholderImage: meta.thumbnail, - filter: nil, - progress: { [weak self] progress in - guard let self = self else { return } - // self.progressBarView.progress.value = CGFloat(progress.fractionCompleted) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: load %s progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription, progress.fractionCompleted) - }, - imageTransition: .crossDissolve(0.3), - runImageTransitionIfCached: false, - completion: { [weak self] response in - guard let self = self else { return } - switch response.result { - case .success(let image): - //self.progressBarView.isHidden = true - self.previewImageView.imageView.image = image - self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) - case .failure(let error): - // TODO: - break - } - } - ) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: setImage url: %s", ((#file as NSString).lastPathComponent), #line, #function, meta.url.debugDescription) - case .local(let meta): - // progressBarView.isHidden = true - previewImageView.imageView.image = meta.image - self.previewImageView.setup(image: meta.image, container: self.previewImageView, forceUpdate: true) - } + viewModel.image + .receive(on: RunLoop.main) // use RunLoop prevent set image during zooming (TODO: handle transitioning state) + .sink { [weak self] image in + guard let self = self else { return } + guard let image = image else { return } + self.previewImageView.imageView.image = image + self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) + } + .store(in: &disposeBag) } } @@ -128,14 +106,16 @@ extension MediaPreviewImageViewController: UIContextMenuInteractionDelegate { } let saveAction = UIAction( - title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + title: L10n.Common.Controls.Actions.savePhoto, image: UIImage(systemName: "square.and.arrow.down")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: save photo", ((#file as NSString).lastPathComponent), #line, #function) guard let self = self else { return } self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .savePhoto) } let shareAction = UIAction( - title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in + title: L10n.Common.Controls.Actions.share, image: UIImage(systemName: "square.and.arrow.up")!, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off + ) { [weak self] _ in os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: share", ((#file as NSString).lastPathComponent), #line, #function) guard let self = self else { return } self.delegate?.mediaPreviewImageViewController(self, contextMenuActionPerform: .share) diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 9215ef61f..6be61dfc4 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -5,20 +5,39 @@ // Created by MainasuK Cirno on 2021-4-28. // +import os.log import UIKit import Combine +import AlamofireImage class MediaPreviewImageViewModel { // input let item: ImagePreviewItem + + // output + let image: CurrentValueSubject init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) + self.image = CurrentValueSubject(meta.thumbnail) + + let url = meta.url + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in + guard let self = self else { return } + switch response.result { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s fail: %s", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription, error.localizedDescription) + case .success(let image): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: download image %s success", ((#file as NSString).lastPathComponent), #line, #function, url.debugDescription) + self.image.value = image + } + }) } init(meta: LocalImagePreviewMeta) { self.item = .local(meta) + self.image = CurrentValueSubject(meta.image) } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift index 83678cd56..01d76f4b8 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController.swift @@ -120,6 +120,22 @@ extension FavoriteViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index d44dd7447..503ce04c3 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -128,6 +128,22 @@ extension UserTimelineViewController: UITableViewDelegate { aspectTableView(tableView, didSelectRowAt: indexPath) } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift new file mode 100644 index 000000000..2a5ba4923 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewController.swift @@ -0,0 +1,61 @@ +// +// ContextMenuImagePreviewViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import func AVFoundation.AVMakeRect +import UIKit +import Combine + +final class ContextMenuImagePreviewViewController: UIViewController { + + var disposeBag = Set() + + var viewModel: ContextMenuImagePreviewViewModel! + + let imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.layer.masksToBounds = true + return imageView + }() + +} + +extension ContextMenuImagePreviewViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + imageView.image = viewModel.thumbnail + + let frame = AVMakeRect(aspectRatio: viewModel.aspectRatio, insideRect: view.bounds) + preferredContentSize = frame.size + + viewModel.url + .sink { [weak self] url in + guard let self = self else { return } + guard let url = url else { return } + self.imageView.af.setImage( + withURL: url, + placeholderImage: self.viewModel.thumbnail, + imageTransition: .crossDissolve(0.2), + runImageTransitionIfCached: true, + completion: nil + ) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift new file mode 100644 index 000000000..f56ff060c --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/ContextMenuImagePreviewViewModel.swift @@ -0,0 +1,25 @@ +// +// ContextMenuImagePreviewViewModel.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit +import Combine + +final class ContextMenuImagePreviewViewModel { + + var disposeBag = Set() + + // input + let aspectRatio: CGSize + let thumbnail: UIImage? + let url = CurrentValueSubject(nil) + + init(aspectRatio: CGSize, thumbnail: UIImage?) { + self.aspectRatio = aspectRatio + self.thumbnail = thumbnail + } + +} diff --git a/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift new file mode 100644 index 000000000..e8e7787f0 --- /dev/null +++ b/Mastodon/Scene/Share/ContextMenu/ImagePreview/TimelineTableViewCellContextMenuConfiguration.swift @@ -0,0 +1,16 @@ +// +// TimelineTableViewCellContextMenuConfiguration.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import UIKit + +// note: use subclass configuration not custom NSCopying identifier due to identifier cause crash issue +final class TimelineTableViewCellContextMenuConfiguration: UIContextMenuConfiguration { + + var indexPath: IndexPath? + var index: Int? + +} diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index bec55cd78..ea943fb0e 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -9,14 +9,14 @@ import os.log import func AVFoundation.AVMakeRect import UIKit -protocol MosaicImageViewContainerPresentable: class { +protocol MosaicImageViewContainerPresentable: AnyObject { var mosaicImageViewContainer: MosaicImageViewContainer { get } + var isRevealing: Bool { get } } -protocol MosaicImageViewContainerDelegate: class { +protocol MosaicImageViewContainerDelegate: AnyObject { func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) func mosaicImageViewContainer(_ mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) - } final class MosaicImageViewContainer: UIView { @@ -296,7 +296,7 @@ extension MosaicImageViewContainer { } -// FIXME: set imageView source from blurhash and image +// FIXME: refactor blurhash image and preview image extension MosaicImageViewContainer { func setImageViews(alpha: CGFloat) { @@ -313,6 +313,22 @@ extension MosaicImageViewContainer { } } + func thumbnail(at index: Int) -> UIImage? { + guard blurhashOverlayImageViews.count == imageViews.count else { return nil } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + guard index < tuples.count else { return nil } + let tuple = tuples[index] + return tuple.1.image ?? tuple.0.image + } + + func thumbnails() -> [UIImage?] { + guard blurhashOverlayImageViews.count == imageViews.count else { return [] } + let tuples = Array(zip(blurhashOverlayImageViews, imageViews)) + return tuples.map { blurhashOverlayImageView, imageView -> UIImage? in + return imageView.image ?? blurhashOverlayImageView.image + } + } + } extension MosaicImageViewContainer { diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 40eb05a58..34367d396 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -194,6 +194,8 @@ final class StatusView: UIView { let activeTextLabel = ActiveLabel(style: .default) private let headerInfoLabelTapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + var isRevealing = true override init(frame: CGRect) { super.init(frame: frame) @@ -468,6 +470,8 @@ extension StatusView { } func updateRevealContentWarningButton(isRevealing: Bool) { + self.isRevealing = isRevealing + if !isRevealing { let image = traitCollection.userInterfaceStyle == .light ? UIImage(systemName: "eye")! : UIImage(systemName: "eye.fill") revealContentWarningButton.setImage(image, for: .normal) diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index d546fea62..5d27a6a93 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,7 +13,7 @@ import CoreData import CoreDataStack import ActiveLabel -protocol StatusTableViewCellDelegate: class { +protocol StatusTableViewCellDelegate: AnyObject { var context: AppContext! { get } var managedObjectContext: NSManagedObjectContext { get } @@ -48,7 +48,7 @@ extension StatusTableViewCellDelegate { } final class StatusTableViewCell: UITableViewCell, StatusCell { - + static let bottomPaddingHeight: CGFloat = 10 weak var delegate: StatusTableViewCellDelegate? @@ -62,7 +62,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { let threadMetaStackView = UIStackView() let threadMetaView = ThreadMetaView() let separatorLine = UIView.separatorLine - + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! @@ -206,6 +206,19 @@ extension StatusTableViewCell { } } +// MARK: - MosaicImageViewContainerPresentable +extension StatusTableViewCell: MosaicImageViewContainerPresentable { + + var mosaicImageViewContainer: MosaicImageViewContainer { + return statusView.statusMosaicImageViewContainer + } + + var isRevealing: Bool { + return statusView.isRevealing + } + +} + // MARK: - UITableViewDelegate extension StatusTableViewCell: UITableViewDelegate { diff --git a/Mastodon/Scene/Thread/ThreadViewController.swift b/Mastodon/Scene/Thread/ThreadViewController.swift index 8ca8a3395..6c801ae4f 100644 --- a/Mastodon/Scene/Thread/ThreadViewController.swift +++ b/Mastodon/Scene/Thread/ThreadViewController.swift @@ -151,6 +151,22 @@ extension ThreadViewController: UITableViewDelegate { } } + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + } // MARK: - UITableViewDataSourcePrefetching diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index a0bf523f9..74d82badd 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -360,7 +360,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { let progress = progressStep(for: translation) let initialSize = transitionItem.initialFrame!.size - assert(initialSize != .zero) + guard initialSize != .zero else { return } + // assert(initialSize != .zero) guard let snapshot = transitionItem.snapshotTransitioning, let finalSize = transitionItem.targetFrame?.size else { diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift index c1de3023b..225a83209 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionController.swift @@ -46,7 +46,7 @@ extension MediaPreviewTransitionController { extension MediaPreviewTransitionController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === panGestureRecognizer { + if gestureRecognizer === panGestureRecognizer || otherGestureRecognizer === panGestureRecognizer { // FIXME: should enable zoom up pan dismiss return false } diff --git a/Mastodon/Vender/CustomScheduler.swift b/Mastodon/Vender/CustomScheduler.swift new file mode 100644 index 000000000..bf87ce053 --- /dev/null +++ b/Mastodon/Vender/CustomScheduler.swift @@ -0,0 +1,50 @@ +// +// CustomScheduler.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-4-30. +// + +import Foundation +import Combine + +// Ref: https://stackoverflow.com/a/59069315/3797903 +struct CustomScheduler: Scheduler { + var runLoop: RunLoop + var modes: [RunLoop.Mode] = [.default] + + func schedule(after date: RunLoop.SchedulerTimeType, interval: RunLoop.SchedulerTimeType.Stride, + tolerance: RunLoop.SchedulerTimeType.Stride, options: Never?, + _ action: @escaping () -> Void) -> Cancellable { + let timer = Timer(fire: date.date, interval: interval.magnitude, repeats: true) { timer in + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + return AnyCancellable { + timer.invalidate() + } + } + + func schedule(after date: RunLoop.SchedulerTimeType, tolerance: RunLoop.SchedulerTimeType.Stride, + options: Never?, _ action: @escaping () -> Void) { + let timer = Timer(fire: date.date, interval: 0, repeats: false) { timer in + timer.invalidate() + action() + } + for mode in modes { + runLoop.add(timer, forMode: mode) + } + } + + func schedule(options: Never?, _ action: @escaping () -> Void) { + runLoop.perform(inModes: modes, block: action) + } + + var now: RunLoop.SchedulerTimeType { RunLoop.SchedulerTimeType(Date()) } + var minimumTolerance: RunLoop.SchedulerTimeType.Stride { RunLoop.SchedulerTimeType.Stride(0.1) } + + typealias SchedulerTimeType = RunLoop.SchedulerTimeType + typealias SchedulerOptions = Never +} From f755a0eb2d690c2fc83715614c096841f213146a Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:34:45 +0800 Subject: [PATCH 361/400] fix: close button highlight --- Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index 5684a6ba4..a803e9503 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -34,7 +34,8 @@ final class MediaPreviewViewController: UIViewController, NeedsDependency { let closeButtonBackgroundVisualEffectView = UIVisualEffectView(effect: UIVibrancyEffect(blurEffect: UIBlurEffect(style: .systemUltraThinMaterial))) let closeButton: UIButton = { - let button = HitTestExpandedButton() + let button = HighlightDimmableButton() + button.expandEdgeInsets = UIEdgeInsets(top: -10, left: -10, bottom: -10, right: -10) button.imageView?.tintColor = .label button.setImage(UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .bold))!, for: .normal) return button From 277d574254e2b4cc96777d2f2ce6df46923e3d1d Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 30 Apr 2021 19:51:05 +0800 Subject: [PATCH 362/400] chore: remove orig files --- ...elineViewController+DebugAction.swift.orig | 353 ------------------ .../Settings/SettingsViewModel.swift.orig | 215 ----------- 2 files changed, 568 deletions(-) delete mode 100644 Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig delete mode 100644 Mastodon/Scene/Settings/SettingsViewModel.swift.orig diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig deleted file mode 100644 index a47aded6b..000000000 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+DebugAction.swift.orig +++ /dev/null @@ -1,353 +0,0 @@ -// -// HomeTimelineViewController+DebugAction.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-2-5. -// - -import os.log -import UIKit -import CoreData -import CoreDataStack - -#if DEBUG -extension HomeTimelineViewController { - var debugMenu: UIMenu { - let menu = UIMenu( - title: "Debug Tools", - image: nil, - identifier: nil, - options: .displayInline, - children: [ - moveMenu, - dropMenu, - UIAction(title: "Show Welcome", image: UIImage(systemName: "figure.walk"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showWelcomeAction(action) - }, - UIAction(title: "Show Or Remove EmptyView", image: UIImage(systemName: "clear"), attributes: []) { [weak self] action in - guard let self = self else { return } - if self.emptyView.superview != nil { - self.emptyView.removeFromSuperview() - } else { - self.showEmptyView() - } - }, - UIAction(title: "Show Public Timeline", image: UIImage(systemName: "list.dash"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showPublicTimelineAction(action) - }, - UIAction(title: "Show Profile", image: UIImage(systemName: "person.crop.circle"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showProfileAction(action) - }, - UIAction(title: "Show Thread", image: UIImage(systemName: "bubble.left.and.bubble.right"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showThreadAction(action) - }, - UIAction(title: "Settings", image: UIImage(systemName: "gear"), attributes: []) { [weak self] action in - guard let self = self else { return } - self.showSettings(action) - }, - UIAction(title: "Sign Out", image: UIImage(systemName: "escape"), attributes: .destructive) { [weak self] action in - guard let self = self else { return } - self.signOutAction(action) - } - ] - ) - return menu - } - - var moveMenu: UIMenu { - return UIMenu( - title: "Move to…", - image: UIImage(systemName: "arrow.forward.circle"), - identifier: nil, - options: [], - children: [ - UIAction(title: "First Gap", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToTopGapAction(action) - }), - UIAction(title: "First Replied Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstRepliedStatus(action) - }), - UIAction(title: "First Reblog Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstReblogStatus(action) - }), - UIAction(title: "First Poll Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstPollStatus(action) - }), - UIAction(title: "First Audio Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstAudioStatus(action) - }), - UIAction(title: "First Video Status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstVideoStatus(action) - }), - UIAction(title: "First GIF status", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.moveToFirstGIFStatus(action) - }), - ] - ) - } - - var dropMenu: UIMenu { - return UIMenu( - title: "Drop…", - image: UIImage(systemName: "minus.circle"), - identifier: nil, - options: [], - children: [50, 100, 150, 200, 250, 300].map { count in - UIAction(title: "Drop Recent \(count) Statuses", image: nil, attributes: [], handler: { [weak self] action in - guard let self = self else { return } - self.dropRecentStatusAction(action, count: count) - }) - } - ) - } -} - -extension HomeTimelineViewController { - - @objc private func moveToTopGapAction(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeMiddleLoader: return true - default: return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - } - } - - @objc private func moveToFirstReblogStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - return homeTimelineIndex.status.reblog != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found reblog status") - } - } - - @objc private func moveToFirstPollStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let post = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return post.poll != nil - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found poll status") - } - } - - @objc private func moveToFirstRepliedStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - guard homeTimelineIndex.status.inReplyToID != nil else { - return false - } - return true - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found replied status") - } - } - - @objc private func moveToFirstAudioStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .audio }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found audio status") - } - } - - @objc private func moveToFirstVideoStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .video }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found video status") - } - } - - @objc private func moveToFirstGIFStatus(_ sender: UIAction) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - let item = snapshotTransitioning.itemIdentifiers.first(where: { item in - switch item { - case .homeTimelineIndex(let objectID, _): - let homeTimelineIndex = viewModel.fetchedResultsController.managedObjectContext.object(with: objectID) as! HomeTimelineIndex - let status = homeTimelineIndex.status.reblog ?? homeTimelineIndex.status - return status.mediaAttachments?.contains(where: { $0.type == .gifv }) ?? false - default: - return false - } - }) - if let targetItem = item, let index = snapshotTransitioning.indexOfItem(targetItem) { - tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true) - tableView.blinkRow(at: IndexPath(row: index, section: 0)) - } else { - print("Not found GIF status") - } - } - - @objc private func dropRecentStatusAction(_ sender: UIAction, count: Int) { - guard let diffableDataSource = viewModel.diffableDataSource else { return } - let snapshotTransitioning = diffableDataSource.snapshot() - - let droppingObjectIDs = snapshotTransitioning.itemIdentifiers.prefix(count).compactMap { item -> NSManagedObjectID? in - switch item { - case .homeTimelineIndex(let objectID, _): return objectID - default: return nil - } - } - var droppingStatusObjectIDs: [NSManagedObjectID] = [] - context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingObjectIDs { - guard let homeTimelineIndex = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex else { continue } - droppingStatusObjectIDs.append(homeTimelineIndex.status.objectID) - self.context.apiService.backgroundManagedObjectContext.delete(homeTimelineIndex) - } - } - .sink { [weak self] result in - guard let self = self else { return } - switch result { - case .success: - self.context.apiService.backgroundManagedObjectContext.performChanges { [weak self] in - guard let self = self else { return } - for objectID in droppingStatusObjectIDs { - guard let post = try? self.context.apiService.backgroundManagedObjectContext.existingObject(with: objectID) as? Status else { continue } - self.context.apiService.backgroundManagedObjectContext.delete(post) - } - } - .sink { _ in - // do nothing - } - .store(in: &self.disposeBag) - case .failure(let error): - assertionFailure(error.localizedDescription) - } - } - .store(in: &disposeBag) - } - - @objc private func showWelcomeAction(_ sender: UIAction) { - coordinator.present(scene: .welcome, from: self, transition: .modal(animated: true, completion: nil)) - } - - @objc private func showPublicTimelineAction(_ sender: UIAction) { - coordinator.present(scene: .publicTimeline, from: self, transition: .show) - } - - @objc private func showProfileAction(_ sender: UIAction) { - let alertController = UIAlertController(title: "Enter User ID", message: nil, preferredStyle: .alert) - alertController.addTextField() - let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in - guard let self = self else { return } - guard let textField = alertController?.textFields?.first else { return } - let profileViewModel = RemoteProfileViewModel(context: self.context, userID: textField.text ?? "") - self.coordinator.present(scene: .profile(viewModel: profileViewModel), from: self, transition: .show) - } - alertController.addAction(showAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) - } - - @objc private func showThreadAction(_ sender: UIAction) { - let alertController = UIAlertController(title: "Enter Status ID", message: nil, preferredStyle: .alert) - alertController.addTextField() - let showAction = UIAlertAction(title: "Show", style: .default) { [weak self, weak alertController] _ in - guard let self = self else { return } - guard let textField = alertController?.textFields?.first else { return } - let threadViewModel = RemoteThreadViewModel(context: self.context, statusID: textField.text ?? "") - self.coordinator.present(scene: .thread(viewModel: threadViewModel), from: self, transition: .show) - } - alertController.addAction(showAction) - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - alertController.addAction(cancelAction) - coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) - } - - @objc private func showSettings(_ sender: UIAction) { -<<<<<<< HEAD - guard let currentSetting = context.settingService.currentSetting.value else { return } - let settingsViewModel = SettingsViewModel(context: context, setting: currentSetting) - coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) -======= - let viewModel = SettingsViewModel(context: context) - coordinator.present( - scene: .settings(viewModel: viewModel), - from: self, - transition: .modal(animated: true, completion: nil) - ) ->>>>>>> 2e8183adc646f2871b530b642717e3aab782721d - } -} -#endif diff --git a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig b/Mastodon/Scene/Settings/SettingsViewModel.swift.orig deleted file mode 100644 index c5ae31a89..000000000 --- a/Mastodon/Scene/Settings/SettingsViewModel.swift.orig +++ /dev/null @@ -1,215 +0,0 @@ -// -// SettingsViewModel.swift -// Mastodon -// -// Created by ihugo on 2021/4/7. -// - -import Combine -import CoreData -import CoreDataStack -import Foundation -import MastodonSDK -import UIKit -import os.log - -<<<<<<< HEAD -class SettingsViewModel { -======= -class SettingsViewModel: NSObject { - // confirm set only once - weak var context: AppContext! { willSet { precondition(context == nil) } } ->>>>>>> 2e8183adc646f2871b530b642717e3aab782721d - - var disposeBag = Set() - - let context: AppContext - - // input - let setting: CurrentValueSubject - var updateDisposeBag = Set() - var createDisposeBag = Set() - - let viewDidLoad = PassthroughSubject() - - // output - var dataSource: UITableViewDiffableDataSource! - /// create a subscription when: - /// - does not has one - /// - does not find subscription for selected trigger when change trigger - let createSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() - - /// update a subscription when: - /// - change switch for specified alerts - let updateSubscriptionSubject = PassthroughSubject<(triggerBy: String, values: [Bool?]), Never>() - - lazy var privacyURL: URL? = { - guard let box = AppContext.shared.authenticationService.activeMastodonAuthenticationBox.value else { - return nil - } - - return Mastodon.API.privacyURL(domain: box.domain) - }() - -<<<<<<< HEAD - init(context: AppContext, setting: Setting) { - self.context = context - self.setting = CurrentValueSubject(setting) -======= - /// to store who trigger the notification. - var triggerBy: String? - - struct Input { - } - - struct Output { - } - - init(context: AppContext) { - self.context = context ->>>>>>> 2e8183adc646f2871b530b642717e3aab782721d - - self.setting - .sink(receiveValue: { [weak self] setting in - guard let self = self else { return } - self.processDataSource(setting) - }) - .store(in: &disposeBag) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s:", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension SettingsViewModel { - - // MARK: - Private methods - private func processDataSource(_ setting: Setting) { - guard let dataSource = self.dataSource else { return } - var snapshot = NSDiffableDataSourceSnapshot() - - // appearance - let appearanceItems = [SettingsItem.apperance(settingObjectID: setting.objectID)] - snapshot.appendSections([.apperance]) - snapshot.appendItems(appearanceItems, toSection: .apperance) - - let notificationItems = SettingsItem.NotificationSwitchMode.allCases.map { mode in - SettingsItem.notification(settingObjectID: setting.objectID, switchMode: mode) - } - snapshot.appendSections([.notifications]) - snapshot.appendItems(notificationItems, toSection: .notifications) - - // boring zone - let boringZoneSettingsItems: [SettingsItem] = { - let links: [SettingsItem.Link] = [ - .termsOfService, - .privacyPolicy - ] - let items = links.map { SettingsItem.boringZone(item: $0) } - return items - }() - snapshot.appendSections([.boringZone]) - snapshot.appendItems(boringZoneSettingsItems, toSection: .boringZone) - - let spicyZoneSettingsItems: [SettingsItem] = { - let links: [SettingsItem.Link] = [ - .clearMediaCache, - .signOut - ] - let items = links.map { SettingsItem.spicyZone(item: $0) } - return items - }() - snapshot.appendSections([.spicyZone]) - snapshot.appendItems(spicyZoneSettingsItems, toSection: .spicyZone) - - dataSource.apply(snapshot, animatingDifferences: false) - } - -} - -extension SettingsViewModel { - func setupDiffableDataSource( - for tableView: UITableView, - settingsAppearanceTableViewCellDelegate: SettingsAppearanceTableViewCellDelegate, - settingsToggleCellDelegate: SettingsToggleCellDelegate - ) { - dataSource = UITableViewDiffableDataSource(tableView: tableView) { [ - weak self, - weak settingsAppearanceTableViewCellDelegate, - weak settingsToggleCellDelegate - ] tableView, indexPath, item -> UITableViewCell? in - guard let self = self else { return nil } - - switch item { - case .apperance(let objectID): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsAppearanceTableViewCell.self), for: indexPath) as! SettingsAppearanceTableViewCell - self.context.managedObjectContext.performAndWait { - let setting = self.context.managedObjectContext.object(with: objectID) as! Setting - cell.update(with: setting.appearance) - ManagedObjectObserver.observe(object: setting) - .sink(receiveCompletion: { _ in - // do nothing - }, receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let setting = object as? Setting else { return } - cell.update(with: setting.appearance) - }) - .store(in: &cell.disposeBag) - } - cell.delegate = settingsAppearanceTableViewCellDelegate - return cell - case .notification(let objectID, let switchMode): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsToggleTableViewCell.self), for: indexPath) as! SettingsToggleTableViewCell - self.context.managedObjectContext.performAndWait { - let setting = self.context.managedObjectContext.object(with: objectID) as! Setting - if let subscription = setting.activeSubscription { - SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) - } - ManagedObjectObserver.observe(object: setting) - .sink(receiveCompletion: { _ in - // do nothing - }, receiveValue: { [weak cell] change in - guard let cell = cell else { return } - guard case .update(let object) = change.changeType, - let setting = object as? Setting else { return } - guard let subscription = setting.activeSubscription else { return } - SettingsViewModel.configureSettingToggle(cell: cell, switchMode: switchMode, subscription: subscription) - }) - .store(in: &cell.disposeBag) - } - cell.delegate = settingsToggleCellDelegate - return cell - case .boringZone(let item), .spicyZone(let item): - let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SettingsLinkTableViewCell.self), for: indexPath) as! SettingsLinkTableViewCell - cell.update(with: item) - return cell - } - } - - processDataSource(self.setting.value) - } -} - -extension SettingsViewModel { - - static func configureSettingToggle( - cell: SettingsToggleTableViewCell, - switchMode: SettingsItem.NotificationSwitchMode, - subscription: NotificationSubscription - ) { - cell.textLabel?.text = switchMode.title - - let enabled: Bool? - switch switchMode { - case .favorite: enabled = subscription.alert.favourite - case .follow: enabled = subscription.alert.follow - case .reblog: enabled = subscription.alert.reblog - case .mention: enabled = subscription.alert.mention - } - cell.update(enabled: enabled) - } - -} From 597fc3fa1ab2842671c1bb8eb87f35197d75793e Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 14:41:48 +0800 Subject: [PATCH 363/400] chore: set image url fallback --- Mastodon/Extension/CoreDataStack/MastodonUser.swift | 8 ++++++++ Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index bb99b15d4..7035a987b 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -58,10 +58,18 @@ extension MastodonUser { return URL(string: header) } + public func headerImageURLWithFallback(domain: String) -> URL { + return URL(string: header) ?? URL(string: "https://\(domain)/headers/original/missing.png")! + } + public func avatarImageURL() -> URL? { return URL(string: avatar) } + public func avatarImageURLWithFallback(domain: String) -> URL { + return URL(string: avatar) ?? URL(string: "https://\(domain)/avatars/original/missing.png")! + } + } extension MastodonUser { diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index 1b0cc6def..f3037c080 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -59,7 +59,7 @@ final class MediaPreviewViewModel: NSObject { let managedObjectContext = self.context.managedObjectContext managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.headerImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let avatarURL = account.headerImageURLWithFallback(domain: account.domain) let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() @@ -79,7 +79,7 @@ final class MediaPreviewViewModel: NSObject { let managedObjectContext = self.context.managedObjectContext managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser - let avatarURL = account.avatarImageURL() ?? URL(string: "https://example.com")! // assert URL exist + let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() From 51c01066d323315ff93c674036f01f6a7cc9f1c3 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 15:05:24 +0800 Subject: [PATCH 364/400] feat: add photo library permission checking --- Localization/app.json | 9 +++++++-- Mastodon/Generated/Strings.swift | 8 ++++++++ .../StatusProvider+UITableViewDelegate.swift | 14 ++++++++++++-- Mastodon/Resources/en.lproj/Localizable.strings | 3 +++ .../MediaPreviewViewController.swift | 13 +++++++++++-- Mastodon/Service/PhotoLibraryService.swift | 14 ++++++++++++++ Mastodon/Service/SettingService.swift | 16 ++++++++++++++++ 7 files changed, 71 insertions(+), 6 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index e6be2d09f..4d6dcbb2d 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -27,6 +27,10 @@ "title": "Sign out", "message": "Are you sure you want to sign out?", "confirm": "Sign Out" + }, + "save_photo_failure": { + "title": "Save Photo Failure", + "message": "Please enable photo libaray access permission to save photo." } }, "controls": { @@ -55,7 +59,8 @@ "find_people": "Find people to follow", "manually_search": "Manually search instead", "skip": "Skip", - "report_user": "Report %s" + "report_user": "Report %s", + "settings": "Settings" }, "status": { "user_reblogged": "%s reblogged", @@ -411,4 +416,4 @@ "text_placeholder": "Type or paste additional comments" } } -} +} \ No newline at end of file diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 75e3cdd91..b4908fea4 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -31,6 +31,12 @@ internal enum L10n { /// Publish Failure internal static let title = L10n.tr("Localizable", "Common.Alerts.PublishPostFailure.Title") } + internal enum SavePhotoFailure { + /// Please enable photo libaray access permission to save photo. + internal static let message = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Message") + /// Save Photo Failure + internal static let title = L10n.tr("Localizable", "Common.Alerts.SavePhotoFailure.Title") + } internal enum ServerError { /// Server Error internal static let title = L10n.tr("Localizable", "Common.Alerts.ServerError.Title") @@ -94,6 +100,8 @@ internal enum L10n { internal static let savePhoto = L10n.tr("Localizable", "Common.Controls.Actions.SavePhoto") /// See More internal static let seeMore = L10n.tr("Localizable", "Common.Controls.Actions.SeeMore") + /// Settings + internal static let settings = L10n.tr("Localizable", "Common.Controls.Actions.Settings") /// Share internal static let share = L10n.tr("Localizable", "Common.Controls.Actions.Share") /// Share %@ diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index ef19abab9..46e4c5ab5 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -171,8 +171,18 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let attachment = attachment, let url = URL(string: attachment.url) else { return nil } return self.context.photoLibraryService.saveImage(url: url) } - .sink(receiveCompletion: { _ in - // do nothing + .switchToLatest() + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + guard let error = error as? PhotoLibraryService.PhotoLibraryError, + case .noPermission = error else { return } + let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + case .finished: + break + } }, receiveValue: { _ in // do nothing }) diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 1bb3f54a3..2249b15f8 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -5,6 +5,8 @@ "Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. Please check your internet connection."; "Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; +"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; "Common.Alerts.ServerError.Title" = "Server Error"; "Common.Alerts.SignOut.Confirm" = "Sign Out"; "Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; @@ -30,6 +32,7 @@ Please check your internet connection."; "Common.Controls.Actions.Save" = "Save"; "Common.Controls.Actions.SavePhoto" = "Save photo"; "Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Settings"; "Common.Controls.Actions.Share" = "Share"; "Common.Controls.Actions.ShareUser" = "Share %@"; "Common.Controls.Actions.SignIn" = "Sign In"; diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index a803e9503..eee56e4d0 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -208,8 +208,17 @@ extension MediaPreviewViewController: MediaPreviewImageViewControllerDelegate { switch viewController.viewModel.item { case .status(let meta): context.photoLibraryService.saveImage(url: meta.url) - .sink { _ in - // do nothing + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + guard let error = error as? PhotoLibraryService.PhotoLibraryError, + case .noPermission = error else { return } + let alertController = SettingService.openSettingsAlertController(title: L10n.Common.Alerts.SavePhotoFailure.title, message: L10n.Common.Alerts.SavePhotoFailure.message) + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + case .finished: + break + } } receiveValue: { _ in // do nothing } diff --git a/Mastodon/Service/PhotoLibraryService.swift b/Mastodon/Service/PhotoLibraryService.swift index 44918eb53..2dcc8f990 100644 --- a/Mastodon/Service/PhotoLibraryService.swift +++ b/Mastodon/Service/PhotoLibraryService.swift @@ -8,12 +8,21 @@ import os.log import UIKit import Combine +import Photos import AlamofireImage final class PhotoLibraryService: NSObject { } +extension PhotoLibraryService { + + enum PhotoLibraryError: Error { + case noPermission + } + +} + extension PhotoLibraryService { func saveImage(url: URL) -> AnyPublisher { @@ -21,6 +30,11 @@ extension PhotoLibraryService { let notificationFeedbackGenerator = UINotificationFeedbackGenerator() return Future { promise in + guard PHPhotoLibrary.authorizationStatus(for: .addOnly) != .denied else { + promise(.failure(PhotoLibraryError.noPermission)) + return + } + ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in guard let self = self else { return } switch response.result { diff --git a/Mastodon/Service/SettingService.swift b/Mastodon/Service/SettingService.swift index 8683b3972..f0375bad2 100644 --- a/Mastodon/Service/SettingService.swift +++ b/Mastodon/Service/SettingService.swift @@ -171,3 +171,19 @@ final class SettingService { } } + +extension SettingService { + + static func openSettingsAlertController(title: String, message: String) -> UIAlertController { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let settingAction = UIAlertAction(title: L10n.Common.Controls.Actions.settings, style: .default) { _ in + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + alertController.addAction(settingAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) + alertController.addAction(cancelAction) + return alertController + } + +} From 2a18244d84b6ea50b4bd685c0cd28ecaee8f80e7 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 16:24:21 +0800 Subject: [PATCH 365/400] fix: learn word cost too much CPU issue. Update TwitterTextEditor package source to upstream --- Mastodon.xcodeproj/project.pbxproj | 38 +++++++++---------- .../xcschemes/xcschememanagement.plist | 4 +- .../xcshareddata/swiftpm/Package.resolved | 10 ++--- .../Scene/Compose/ComposeViewController.swift | 22 ++--------- ...rvice+CustomEmojiViewModel+LoadState.swift | 2 +- .../EmojiService+CustomEmojiViewModel.swift | 21 ++++++++++ 6 files changed, 52 insertions(+), 45 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2d5c07115..6b1b9ed18 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -202,6 +202,8 @@ DB2B3AE925E38850007045F9 /* UIViewPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2B3AE825E38850007045F9 /* UIViewPreview.swift */; }; DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2F073325E8ECF000957B2D /* AuthenticationViewModel.swift */; }; DB2FF510260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB2FF50F260B113300ADA9FE /* ComposeStatusPollExpiresOptionCollectionViewCell.swift */; }; + DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; }; + DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DB35B0B22643D821006AC73B /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DB35FC1F2612F1D9006193C9 /* ProfileRelationshipActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC1E2612F1D9006193C9 /* ProfileRelationshipActionButton.swift */; }; DB35FC252612FD7A006193C9 /* ProfileFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC242612FD7A006193C9 /* ProfileFieldView.swift */; }; DB35FC2F26130172006193C9 /* MastodonField.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB35FC2E26130172006193C9 /* MastodonField.swift */; }; @@ -434,8 +436,6 @@ DBE54ABF2636C889004E7C0B /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6D1B23263684C600ACB481 /* UserDefaults.swift */; }; DBE54AC62636C89F004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; DBE54ACC2636C8FD004E7C0B /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE54AC52636C89F004E7C0B /* NotificationPreference.swift */; }; - DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; }; - DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */ = {isa = PBXBuildFile; productRef = DBE64A8A260C49D200E6359A /* TwitterTextEditor */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; DBF8AE16263293E400C9C23C /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF8AE15263293E400C9C23C /* NotificationService.swift */; }; DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = DBF8AE13263293E400C9C23C /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DBF8AE862632992800C9C23C /* Base85 in Frameworks */ = {isa = PBXBuildFile; productRef = DBF8AE852632992800C9C23C /* Base85 */; }; @@ -545,8 +545,8 @@ dstSubfolderSpec = 10; files = ( DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */, - DBE64A8C260C49D200E6359A /* TwitterTextEditor in Embed Frameworks */, DB89BA0425C10FD0008580ED /* CoreDataStack.framework in Embed Frameworks */, + DB35B0B42643D821006AC73B /* TwitterTextEditor in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -1006,6 +1006,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + DB35B0B32643D821006AC73B /* TwitterTextEditor in Frameworks */, DB0140BD25C40D7500F9F3CF /* CommonOSLog in Frameworks */, DB89BA0325C10FD0008580ED /* CoreDataStack.framework in Frameworks */, 2D42FF6125C8177C004A627A /* ActiveLabel in Frameworks */, @@ -1018,7 +1019,6 @@ DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DB3D0FF325BAA61700EAA174 /* AlamofireImage in Frameworks */, - DBE64A8B260C49D200E6359A /* TwitterTextEditor in Frameworks */, 2D5981BA25E4D7F8000FB903 /* ThirdPartyMailer in Frameworks */, 87FFDA5D898A5C42ADCB35E7 /* Pods_Mastodon.framework in Frameworks */, DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */, @@ -2408,8 +2408,8 @@ 2D5981B925E4D7F8000FB903 /* ThirdPartyMailer */, 2D939AC725EE14620076FA61 /* CropViewController */, DB9A487D2603456B008B817C /* UITextView+Placeholder */, - DBE64A8A260C49D200E6359A /* TwitterTextEditor */, DBB525072611EAC0002F1F29 /* Tabman */, + DB35B0B22643D821006AC73B /* TwitterTextEditor */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -2596,10 +2596,10 @@ 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */, 2D939AC625EE14620076FA61 /* XCRemoteSwiftPackageReference "TOCropViewController" */, DB9A487C2603456B008B817C /* XCRemoteSwiftPackageReference "UITextView-Placeholder" */, - DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */, DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */, DB6804722637CC1200430867 /* XCRemoteSwiftPackageReference "KeychainAccess" */, + DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3937,6 +3937,14 @@ minimumVersion = 0.1.1; }; }; + DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twitter/TwitterTextEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Alamofire/AlamofireImage.git"; @@ -3977,14 +3985,6 @@ minimumVersion = 2.11.0; }; }; - DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/MainasuK/TwitterTextEditor"; - requirement = { - branch = "feature/input-view"; - kind = branch; - }; - }; DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/MainasuK/Base85.git"; @@ -4030,6 +4030,11 @@ package = DB0140BB25C40D7500F9F3CF /* XCRemoteSwiftPackageReference "CommonOSLog" */; productName = CommonOSLog; }; + DB35B0B22643D821006AC73B /* TwitterTextEditor */ = { + isa = XCSwiftPackageProductDependency; + package = DB35B0B12643D821006AC73B /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; + productName = TwitterTextEditor; + }; DB3D0FF225BAA61700EAA174 /* AlamofireImage */ = { isa = XCSwiftPackageProductDependency; package = DB3D0FF125BAA61700EAA174 /* XCRemoteSwiftPackageReference "AlamofireImage" */; @@ -4060,11 +4065,6 @@ package = DBB525062611EAC0002F1F29 /* XCRemoteSwiftPackageReference "Tabman" */; productName = Tabman; }; - DBE64A8A260C49D200E6359A /* TwitterTextEditor */ = { - isa = XCSwiftPackageProductDependency; - package = DBE64A89260C49D200E6359A /* XCRemoteSwiftPackageReference "TwitterTextEditor" */; - productName = TwitterTextEditor; - }; DBF8AE852632992800C9C23C /* Base85 */ = { isa = XCSwiftPackageProductDependency; package = DBF8AE842632992700C9C23C /* XCRemoteSwiftPackageReference "Base85" */; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 326857269..f092f9734 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 15 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 15 + 14 SuppressBuildableAutocreation diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index b9f39148d..3295adb41 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,7 +69,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", "version": "6.2.1" } }, @@ -138,11 +138,11 @@ }, { "package": "TwitterTextEditor", - "repositoryURL": "https://github.com/MainasuK/TwitterTextEditor", + "repositoryURL": "https://github.com/twitter/TwitterTextEditor", "state": { - "branch": "feature/input-view", - "revision": "1e565d13e3c26fc2bedeb418890df42f80d6e3d5", - "version": null + "branch": null, + "revision": "dfe0edc3bcb6703ee2fd0e627f95e726b63e732a", + "version": "1.1.0" } }, { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index a18cf9216..dedcd4050 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -300,24 +300,9 @@ extension ComposeViewController { } } .store(in: &disposeBag) - - // bind text editor for custom emojis update event - viewModel.customEmojiViewModel - .compactMap { $0?.emojis } - .switchToLatest() - .sink(receiveValue: { [weak self] emojis in - guard let self = self else { return } - for emoji in emojis { - UITextChecker.learnWord(emoji.shortcode) - UITextChecker.learnWord(":" + emoji.shortcode + ":") - } - self.textEditorView()?.setNeedsUpdateTextAttributes() - }) - .store(in: &disposeBag) // bind custom emoji picker UI viewModel.customEmojiViewModel - .receive(on: DispatchQueue.main) .map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in guard let viewModel = viewModel else { return Just([]).eraseToAnyPublisher() @@ -325,6 +310,7 @@ extension ComposeViewController { return viewModel.emojis.eraseToAnyPublisher() } .switchToLatest() + .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] emojis in guard let self = self else { return } if emojis.isEmpty { @@ -581,6 +567,7 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { updateAttributedString attributedString: NSAttributedString, completion: @escaping (NSAttributedString?) -> Void ) { + // FIXME: needs O(1) update completion to fix profermance issue DispatchQueue.global().async { let string = attributedString.string os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string) @@ -631,11 +618,10 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { } // emoji - let emojis = customEmojiViewModel?.emojis.value ?? [] - if !emojis.isEmpty { + if let customEmojiViewModel = customEmojiViewModel, !customEmojiViewModel.emojiDict.value.isEmpty { for match in emojiMatches { guard let name = string.substring(with: match, at: 2) else { continue } - guard let emoji = emojis.first(where: { $0.shortcode == name }) else { continue } + guard let emoji = customEmojiViewModel.emoji(shortcode: name) else { continue } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name) // set emoji token invisiable (without upper bounce space) diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift index 4fdab0bb9..a03af9bd1 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel+LoadState.swift @@ -41,7 +41,7 @@ extension EmojiService.CustomEmojiViewModel.LoadState { guard let viewModel = viewModel, let apiService = viewModel.service?.apiService, let stateMachine = stateMachine else { return } apiService.customEmoji(domain: viewModel.domain) - .receive(on: DispatchQueue.main) + // .receive(on: DispatchQueue.main) .sink { completion in switch completion { case .failure(let error): diff --git a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift index f866f4a02..d1b8494d7 100644 --- a/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift +++ b/Mastodon/Service/EmojiService/EmojiService+CustomEmojiViewModel.swift @@ -32,10 +32,31 @@ extension EmojiService { return stateMachine }() let emojis = CurrentValueSubject<[Mastodon.Entity.Emoji], Never>([]) + let emojiDict = CurrentValueSubject<[String: [Mastodon.Entity.Emoji]], Never>([:]) + + private var learnedEmoji: Set = Set() init(domain: String, service: EmojiService) { self.domain = domain self.service = service + + emojis + .map { Dictionary(grouping: $0, by: { $0.shortcode }) } + .assign(to: \.value, on: emojiDict) + .store(in: &disposeBag) + } + + func emoji(shortcode: String) -> Mastodon.Entity.Emoji? { + if !learnedEmoji.contains(shortcode) { + learnedEmoji.insert(shortcode) + + DispatchQueue.global().async { + UITextChecker.learnWord(shortcode) + UITextChecker.learnWord(":" + shortcode + ":") + } + } + + return emojiDict.value[shortcode]?.first } } From 1424d07e1db84d317a57775875756b2363d54401 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 16:24:53 +0800 Subject: [PATCH 366/400] fix: content warning overlay layout issue --- Mastodon/Scene/Share/View/Content/StatusView.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 34367d396..20437a8d2 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -340,9 +340,10 @@ extension StatusView { contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false containerStackView.addSubview(contentWarningOverlayView) NSLayoutConstraint.activate([ - statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), - statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultLow), - // only layout to top-left corner and draw image to fit size + statusContainerStackView.topAnchor.constraint(equalTo: contentWarningOverlayView.topAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + statusContainerStackView.leftAnchor.constraint(equalTo: contentWarningOverlayView.leftAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + contentWarningOverlayView.rightAnchor.constraint(equalTo: statusContainerStackView.rightAnchor, constant: StatusView.contentWarningBlurRadius).priority(.defaultHigh), + // only layout to top and left & right then draw image to fit size ]) // avoid overlay clip author view containerStackView.bringSubviewToFront(authorContainerStackView) From 5d6004c3dc48321929a3e4770b08710b8ade67f4 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 16:36:10 +0800 Subject: [PATCH 367/400] fix: poll input not trigger publish bar button item state issue --- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 587b56f23..58d1ffa75 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -415,6 +415,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update - // pollOptionAttributes.value = pollOptionAttributes.value + pollOptionAttributes.value = pollOptionAttributes.value } } From d23eaf3dd77e9ba1eb734367b315a62fc09cff09 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 6 May 2021 16:43:54 +0800 Subject: [PATCH 368/400] fix: poll option view using wrong dark mode background color issue --- Mastodon/Scene/Share/View/Content/PollOptionView.swift | 7 ++++++- .../View/TableviewCell/PollOptionTableViewCell.swift | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Mastodon/Scene/Share/View/Content/PollOptionView.swift b/Mastodon/Scene/Share/View/Content/PollOptionView.swift index 7125b691c..2a248ec3f 100644 --- a/Mastodon/Scene/Share/View/Content/PollOptionView.swift +++ b/Mastodon/Scene/Share/View/Content/PollOptionView.swift @@ -82,7 +82,7 @@ final class PollOptionView: UIView { extension PollOptionView { private func _init() { // default color in the timeline - roundedBackgroundView.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + roundedBackgroundView.backgroundColor = Asset.Colors.Background.secondarySystemBackground.color roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false addSubview(roundedBackgroundView) @@ -193,6 +193,11 @@ struct PollOptionView_Previews: PreviewProvider { PollOptionView() } .previewLayout(.fixed(width: 375, height: 100)) + UIViewPreview(width: 375) { + PollOptionView() + } + .preferredColorScheme(.dark) + .previewLayout(.fixed(width: 375, height: 100)) } } diff --git a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift index b067896a1..957765e10 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/PollOptionTableViewCell.swift @@ -129,14 +129,16 @@ struct PollTableViewCell_Previews: PreviewProvider { } .previewLayout(.fixed(width: 375, height: 44 + 10)) } + .background(Color(.systemBackground)) } static var previews: some View { Group { - controls.colorScheme(.light) - controls.colorScheme(.dark) + controls + .colorScheme(.light) + controls + .colorScheme(.dark) } - .background(Color.gray) } } From 801129857167dc545af5d33d2f1fb31605c2c4ca Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 6 May 2021 18:03:58 +0800 Subject: [PATCH 369/400] fix: ignore indexPath for userProvider --- .../Section/NotificationSection.swift | 1 - .../Diffiable/Section/ReportSection.swift | 1 - .../Diffiable/Section/StatusSection.swift | 12 ++------- .../StatusProvider+UITableViewDelegate.swift | 4 +-- .../Protocol/UserProvider/UserProvider.swift | 6 ++--- .../UserProvider/UserProviderFacade.swift | 25 ++++++++----------- .../ProfileViewController+UserProvider.swift | 2 +- .../Scene/Profile/ProfileViewController.swift | 5 ++-- .../Search/SearchViewController+Follow.swift | 6 ++--- Mastodon/Service/BlockDomainService.swift | 14 +++++------ 10 files changed, 29 insertions(+), 47 deletions(-) diff --git a/Mastodon/Diffiable/Section/NotificationSection.swift b/Mastodon/Diffiable/Section/NotificationSection.swift index 558f94dd9..ead5d48f8 100644 --- a/Mastodon/Diffiable/Section/NotificationSection.swift +++ b/Mastodon/Diffiable/Section/NotificationSection.swift @@ -50,7 +50,6 @@ extension NotificationSection { let frame = CGRect(x: 0, y: 0, width: tableView.readableContentGuide.layoutFrame.width - NotificationStatusTableViewCell.statusPadding.left - NotificationStatusTableViewCell.statusPadding.right, height: tableView.readableContentGuide.layoutFrame.height) StatusSection.configure( cell: cell, - indexPath: indexPath, dependency: dependency, readableLayoutFrame: frame, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Diffiable/Section/ReportSection.swift b/Mastodon/Diffiable/Section/ReportSection.swift index 07321be96..6faaae6c2 100644 --- a/Mastodon/Diffiable/Section/ReportSection.swift +++ b/Mastodon/Diffiable/Section/ReportSection.swift @@ -41,7 +41,6 @@ extension ReportSection { let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, - indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b5aadbf16..cb38ec8ab 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -49,7 +49,6 @@ extension StatusSection { let timelineIndex = managedObjectContext.object(with: objectID) as! HomeTimelineIndex StatusSection.configure( cell: cell, - indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, @@ -72,7 +71,6 @@ extension StatusSection { let status = managedObjectContext.object(with: objectID) as! Status StatusSection.configure( cell: cell, - indexPath: indexPath, dependency: dependency, readableLayoutFrame: tableView.readableContentGuide.layoutFrame, timestampUpdatePublisher: timestampUpdatePublisher, @@ -138,7 +136,6 @@ extension StatusSection { static func configure( cell: StatusCell, - indexPath: IndexPath, dependency: NeedsDependency, readableLayoutFrame: CGRect?, timestampUpdatePublisher: AnyPublisher, @@ -407,7 +404,6 @@ extension StatusSection { // toolbar StatusSection.configureActionToolBar( cell: statusTableViewCell, - indexPath: indexPath, dependency: dependency, status: status, requestUserID: requestUserID @@ -438,7 +434,6 @@ extension StatusSection { guard let statusTableViewCell = cell as? StatusTableViewCell else { return } StatusSection.configureActionToolBar( cell: statusTableViewCell, - indexPath: indexPath, dependency: dependency, status: status, requestUserID: requestUserID @@ -597,7 +592,6 @@ extension StatusSection { static func configureActionToolBar( cell: StatusTableViewCell, - indexPath: IndexPath, dependency: NeedsDependency, status: Status, requestUserID: String @@ -644,10 +638,10 @@ extension StatusSection { case .none: break } - StatusSection.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) + StatusSection.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } .store(in: &cell.disposeBag) - self.setupStatusMoreButtonMenu(cell: cell, indexPath: indexPath, dependency: dependency, status: status) + self.setupStatusMoreButtonMenu(cell: cell, dependency: dependency, status: status) } static func configurePoll( @@ -777,7 +771,6 @@ extension StatusSection { private static func setupStatusMoreButtonMenu( cell: StatusTableViewCell, - indexPath: IndexPath, dependency: NeedsDependency, status: Status) { @@ -802,7 +795,6 @@ extension StatusSection { isDomainBlocking: isDomainBlocking, provider: userProvider, cell: cell, - indexPath: indexPath, sourceView: cell.statusView.actionToolbarContainer.moreButton, barButtonItem: nil, shareUser: nil, diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift index 46e4c5ab5..98fa2d2cd 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDelegate.swift @@ -114,7 +114,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let imagePreviewPresentableCell = tableView.cellForRow(at: indexPath) as? ImagePreviewPresentableCell else { return nil } guard imagePreviewPresentableCell.isRevealing else { return nil } - let status = status(for: nil, indexPath: indexPath) + let status = self.status(for: nil, indexPath: indexPath) return contextMenuConfiguration(tableView, status: status, imagePreviewPresentableCell: imagePreviewPresentableCell, contextMenuConfigurationForRowAt: indexPath, point: point) } @@ -260,7 +260,7 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard index < imageViews.count else { return } let imageView = imageViews[index] - let status = status(for: nil, indexPath: indexPath) + let status = self.status(for: nil, indexPath: indexPath) let initialFrame: CGRect? = { guard let previewViewController = animator.previewViewController else { return nil } return UIView.findContextMenuPreviewFrameInWindow(previewController: previewViewController) diff --git a/Mastodon/Protocol/UserProvider/UserProvider.swift b/Mastodon/Protocol/UserProvider/UserProvider.swift index 58c7ba5f7..f9939c740 100644 --- a/Mastodon/Protocol/UserProvider/UserProvider.swift +++ b/Mastodon/Protocol/UserProvider/UserProvider.swift @@ -14,14 +14,14 @@ protocol UserProvider: NeedsDependency & DisposeBagCollectable & UIViewControlle // async func mastodonUser() -> Future - func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future + func mastodonUser(for cell: UITableViewCell?) -> Future } extension UserProvider where Self: StatusProvider { - func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + func mastodonUser(for cell: UITableViewCell?) -> Future { Future { [weak self] promise in guard let self = self else { return } - self.status(for: cell, indexPath: indexPath) + self.status(for: cell, indexPath: nil) .sink { status in promise(.success(status?.authorForUserProvider)) } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index dccfd4605..2aeff0e09 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -54,19 +54,18 @@ extension UserProviderFacade { extension UserProviderFacade { static func toggleUserBlockRelationship( provider: UserProvider, - cell: UITableViewCell?, - indexPath: IndexPath? + cell: UITableViewCell? ) -> AnyPublisher, Error> { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } - if let cell = cell, let indexPath = indexPath { + if let cell = cell { return _toggleUserBlockRelationship( context: provider.context, activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + mastodonUser: provider.mastodonUser(for: cell).eraseToAnyPublisher() ) } else { return _toggleUserBlockRelationship( @@ -101,19 +100,18 @@ extension UserProviderFacade { extension UserProviderFacade { static func toggleUserMuteRelationship( provider: UserProvider, - cell: UITableViewCell?, - indexPath: IndexPath? + cell: UITableViewCell? ) -> AnyPublisher, Error> { // prepare authentication guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { assertionFailure() return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() } - if let cell = cell, let indexPath = indexPath { + if let cell = cell { return _toggleUserMuteRelationship( context: provider.context, activeMastodonAuthenticationBox: activeMastodonAuthenticationBox, - mastodonUser: provider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + mastodonUser: provider.mastodonUser(for: cell).eraseToAnyPublisher() ) } else { return _toggleUserMuteRelationship( @@ -155,7 +153,6 @@ extension UserProviderFacade { isDomainBlocking: Bool, provider: UserProvider, cell: UITableViewCell?, - indexPath: IndexPath?, sourceView: UIView?, barButtonItem: UIBarButtonItem?, shareUser: MastodonUser?, @@ -176,8 +173,7 @@ extension UserProviderFacade { UserProviderFacade.toggleUserMuteRelationship( provider: provider, - cell: cell, - indexPath: indexPath + cell: cell ) .sink { _ in // do nothing @@ -205,8 +201,7 @@ extension UserProviderFacade { UserProviderFacade.toggleUserBlockRelationship( provider: provider, - cell: cell, - indexPath: indexPath + cell: cell ) .sink { _ in // do nothing @@ -246,7 +241,7 @@ extension UserProviderFacade { if isDomainBlocking { let unblockDomainAction = UIAction(title: L10n.Common.Controls.Actions.unblockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } - provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell, indexPath: indexPath) + provider.context.blockDomainService.unblockDomain(userProvider: provider, cell: cell) } children.append(unblockDomainAction) } else { @@ -257,7 +252,7 @@ extension UserProviderFacade { } alertController.addAction(cancelAction) let blockDomainAction = UIAlertAction(title: L10n.Common.Alerts.BlockDomain.blockEntireDomain, style: .destructive) { _ in - provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell, indexPath: indexPath) + provider.context.blockDomainService.blockDomain(userProvider: provider, cell: cell) } alertController.addAction(blockDomainAction) provider.present(alertController, animated: true, completion: nil) diff --git a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift index be124a0c2..6bfa132b8 100644 --- a/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift +++ b/Mastodon/Scene/Profile/ProfileViewController+UserProvider.swift @@ -11,7 +11,7 @@ import CoreDataStack import UIKit extension ProfileViewController: UserProvider { - func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + func mastodonUser(for cell: UITableViewCell?) -> Future { return Future { promise in promise(.success(nil)) } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 5d819c6b3..521df753b 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -399,7 +399,6 @@ extension ProfileViewController { isDomainBlocking: isDomainBlocking, provider: self, cell: nil, - indexPath: nil, sourceView: nil, barButtonItem: self.moreMenuBarButtonItem, shareUser: needsShareAction ? mastodonUser : nil, @@ -787,7 +786,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil, indexPath: nil) + UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) .sink { _ in // do nothing } receiveValue: { _ in @@ -809,7 +808,7 @@ extension ProfileViewController: ProfileHeaderViewDelegate { ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil, indexPath: nil) + UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) .sink { _ in // do nothing } receiveValue: { _ in diff --git a/Mastodon/Scene/Search/SearchViewController+Follow.swift b/Mastodon/Scene/Search/SearchViewController+Follow.swift index 6682d846b..a7f7faf90 100644 --- a/Mastodon/Scene/Search/SearchViewController+Follow.swift +++ b/Mastodon/Scene/Search/SearchViewController+Follow.swift @@ -12,7 +12,7 @@ import UIKit extension SearchViewController: UserProvider { - func mastodonUser(for cell: UITableViewCell?, indexPath: IndexPath?) -> Future { + func mastodonUser(for cell: UITableViewCell?) -> Future { return Future { promise in promise(.success(nil)) } @@ -54,7 +54,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unmute, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil, indexPath: nil) + UserProviderFacade.toggleUserMuteRelationship(provider: self, cell: nil) .sink { _ in // do nothing } receiveValue: { _ in @@ -76,7 +76,7 @@ extension SearchViewController: SearchRecommendAccountsCollectionViewCellDelegat ) let unblockAction = UIAlertAction(title: L10n.Common.Controls.Firendship.unblock, style: .default) { [weak self] _ in guard let self = self else { return } - UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil, indexPath: nil) + UserProviderFacade.toggleUserBlockRelationship(provider: self, cell: nil) .sink { _ in // do nothing } receiveValue: { _ in diff --git a/Mastodon/Service/BlockDomainService.swift b/Mastodon/Service/BlockDomainService.swift index 217f1af1f..036083e60 100644 --- a/Mastodon/Service/BlockDomainService.swift +++ b/Mastodon/Service/BlockDomainService.swift @@ -46,16 +46,15 @@ final class BlockDomainService { func blockDomain( userProvider: UserProvider, - cell: UITableViewCell?, - indexPath: IndexPath? + cell: UITableViewCell? ) { guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let context = userProvider.context else { return } var mastodonUser: AnyPublisher - if let cell = cell, let indexPath = indexPath { - mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + if let cell = cell { + mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() } else { mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() } @@ -85,16 +84,15 @@ final class BlockDomainService { func unblockDomain( userProvider: UserProvider, - cell: UITableViewCell?, - indexPath: IndexPath? + cell: UITableViewCell? ) { guard let activeMastodonAuthenticationBox = userProvider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } guard let context = userProvider.context else { return } var mastodonUser: AnyPublisher - if let cell = cell, let indexPath = indexPath { - mastodonUser = userProvider.mastodonUser(for: cell, indexPath: indexPath).eraseToAnyPublisher() + if let cell = cell { + mastodonUser = userProvider.mastodonUser(for: cell).eraseToAnyPublisher() } else { mastodonUser = userProvider.mastodonUser().eraseToAnyPublisher() } From b8f3f4c8863bf701ed2f400a606660d067e0cf6e Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Thu, 6 May 2021 18:19:24 +0800 Subject: [PATCH 370/400] fix: remove ActionToolbarContainer.moreButtonDidPressed --- Localization/app.json | 2 +- .../Diffiable/Section/StatusSection.swift | 5 +- Mastodon/Generated/Strings.swift | 2 +- .../UserProvider/UserProviderFacade.swift | 105 +++++++++--------- .../Resources/en.lproj/Localizable.strings | 2 +- .../Scene/Profile/ProfileViewController.swift | 8 +- .../TableviewCell/StatusTableViewCell.swift | 4 - .../View/ToolBar/ActionToolBarContainer.swift | 7 -- .../Sources/MastodonSDK/Query/Query.swift | 2 +- 9 files changed, 69 insertions(+), 68 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 28b95fa2c..fbc670da6 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -29,7 +29,7 @@ "confirm": "Sign Out" }, "block_domain": { - "message": "Are you really, really sure you want to block the entire %s ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", + "message": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", "block_entire_domain": "Block entire domain" }, "save_photo_failure": { diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index cb38ec8ab..3994229ee 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -780,7 +780,8 @@ extension StatusSection { return } let author = status.authorForUserProvider - let canReport = authenticationBox.userID != author.id + let isMyself = authenticationBox.userID == author.id + let canReport = !isMyself let isInSameDomain = authenticationBox.domain == author.domainFromAcct let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) @@ -788,9 +789,9 @@ extension StatusSection { cell.statusView.actionToolbarContainer.moreButton.showsMenuAsPrimaryAction = true cell.statusView.actionToolbarContainer.moreButton.menu = UserProviderFacade.createProfileActionMenu( for: author, + isMyself: isMyself, isMuting: isMuting, isBlocking: isBlocking, - canReport: canReport, isInSameDomain: isInSameDomain, isDomainBlocking: isDomainBlocking, provider: userProvider, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index b12d78df8..4ec5e7037 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -16,7 +16,7 @@ internal enum L10n { internal enum BlockDomain { /// Block entire domain internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain") - /// Are you really, really sure you want to block the entire %@ ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed. + /// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed. internal static func message(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Message", String(describing: p1)) } diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 2aeff0e09..9e20e414a 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -146,9 +146,9 @@ extension UserProviderFacade { extension UserProviderFacade { static func createProfileActionMenu( for mastodonUser: MastodonUser, + isMyself: Bool, isMuting: Bool, isBlocking: Bool, - canReport: Bool, isInSameDomain: Bool, isDomainBlocking: Bool, provider: UserProvider, @@ -161,62 +161,67 @@ extension UserProviderFacade { var children: [UIMenuElement] = [] let name = mastodonUser.displayNameWithFallback - // mute - let muteAction = UIAction( - title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, - image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), - discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), - attributes: isMuting ? [] : .destructive, - state: .off - ) { [weak provider] _ in - guard let provider = provider else { return } + if !isMyself { + // mute + let muteAction = UIAction( + title: isMuting ? L10n.Common.Controls.Firendship.unmuteUser(name) : L10n.Common.Controls.Firendship.mute, + image: isMuting ? UIImage(systemName: "speaker") : UIImage(systemName: "speaker.slash"), + discoverabilityTitle: isMuting ? nil : L10n.Common.Controls.Firendship.muteUser(name), + attributes: isMuting ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } - UserProviderFacade.toggleUserMuteRelationship( - provider: provider, - cell: cell - ) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing + UserProviderFacade.toggleUserMuteRelationship( + provider: provider, + cell: cell + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isMuting { + children.append(muteAction) + } else { + let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) + children.append(muteMenu) } - .store(in: &provider.context.disposeBag) - } - if isMuting { - children.append(muteAction) - } else { - let muteMenu = UIMenu(title: L10n.Common.Controls.Firendship.muteUser(name), image: UIImage(systemName: "speaker.slash"), options: [], children: [muteAction]) - children.append(muteMenu) } - // block - let blockAction = UIAction( - title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, - image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), - discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), - attributes: isBlocking ? [] : .destructive, - state: .off - ) { [weak provider] _ in - guard let provider = provider else { return } + if !isMyself { + // block + let blockAction = UIAction( + title: isBlocking ? L10n.Common.Controls.Firendship.unblockUser(name) : L10n.Common.Controls.Firendship.block, + image: isBlocking ? UIImage(systemName: "hand.raised.slash") : UIImage(systemName: "hand.raised"), + discoverabilityTitle: isBlocking ? nil : L10n.Common.Controls.Firendship.blockUser(name), + attributes: isBlocking ? [] : .destructive, + state: .off + ) { [weak provider] _ in + guard let provider = provider else { return } - UserProviderFacade.toggleUserBlockRelationship( - provider: provider, - cell: cell - ) - .sink { _ in - // do nothing - } receiveValue: { _ in - // do nothing + UserProviderFacade.toggleUserBlockRelationship( + provider: provider, + cell: cell + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + if isBlocking { + children.append(blockAction) + } else { + let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) + children.append(blockMenu) } - .store(in: &provider.context.disposeBag) } - if isBlocking { - children.append(blockAction) - } else { - let blockMenu = UIMenu(title: L10n.Common.Controls.Firendship.blockUser(name), image: UIImage(systemName: "hand.raised"), options: [], children: [blockAction]) - children.append(blockMenu) - } - if canReport { + + if !isMyself { let reportAction = UIAction(title: L10n.Common.Controls.Actions.reportUser(name), image: UIImage(systemName: "flag"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } guard let authenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index e18674a7b..59f83a5cd 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,5 +1,5 @@ "Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; -"Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@ ? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 521df753b..d186d13df 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -384,17 +384,23 @@ extension ProfileViewController { self.moreMenuBarButtonItem.menu = nil return } + guard let currentMastodonUser = self.viewModel.currentMastodonUser.value else { + self.moreMenuBarButtonItem.menu = nil + return + } guard let currentDomain = self.viewModel.domain.value else { return } let isMuting = relationshipActionOptionSet.contains(.muting) let isBlocking = relationshipActionOptionSet.contains(.blocking) let isDomainBlocking = domains.contains(mastodonUser.domainFromAcct) let needsShareAction = self.viewModel.isMeBarButtonItemsHidden.value let isInSameDomain = mastodonUser.domainFromAcct == currentDomain + let isMyself = currentMastodonUser.id == mastodonUser.id + self.moreMenuBarButtonItem.menu = UserProviderFacade.createProfileActionMenu( for: mastodonUser, + isMyself: isMyself, isMuting: isMuting, isBlocking: isBlocking, - canReport: true, isInSameDomain: isInSameDomain, isDomainBlocking: isDomainBlocking, provider: self, diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 4a897a349..462577a4b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -356,8 +356,4 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { delegate?.statusTableViewCell(self, actionToolbarContainer: actionToolbarContainer, likeButtonDidPressed: sender) } - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) { - - } - } diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index b777207d2..a2f57dee2 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -12,7 +12,6 @@ protocol ActionToolbarContainerDelegate: class { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) - func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, moreButtonDidPressed sender: UIButton) } @@ -63,7 +62,6 @@ extension ActionToolbarContainer { replyButton.addTarget(self, action: #selector(ActionToolbarContainer.replyButtonDidPressed(_:)), for: .touchUpInside) reblogButton.addTarget(self, action: #selector(ActionToolbarContainer.reblogButtonDidPressed(_:)), for: .touchUpInside) favoriteButton.addTarget(self, action: #selector(ActionToolbarContainer.favoriteButtonDidPressed(_:)), for: .touchUpInside) - moreButton.addTarget(self, action: #selector(ActionToolbarContainer.moreButtonDidPressed(_:)), for: .touchUpInside) } } @@ -194,11 +192,6 @@ extension ActionToolbarContainer { delegate?.actionToolbarContainer(self, starButtonDidPressed: sender) } - @objc private func moreButtonDidPressed(_ sender: UIButton) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - delegate?.actionToolbarContainer(self, moreButtonDidPressed: sender) - } - } #if DEBUG diff --git a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift index 7fbe0da9e..7e27eb50a 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/Query.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/Query.swift @@ -62,6 +62,6 @@ protocol PutQuery: RequestQuery { } protocol DeleteQuery: RequestQuery { } extension DeleteQuery { - // By default a `PostQuery` does not has query items + // By default a `DeleteQuery` does not has query items var queryItems: [URLQueryItem]? { nil } } From 5278002c1562cfc5b3596e7b4112c22ebbce2c0a Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 7 May 2021 16:08:07 +0800 Subject: [PATCH 371/400] feat: Add post delete action entry for user posts --- Localization/app.json | 7 ++- Mastodon.xcodeproj/project.pbxproj | 2 - .../xcshareddata/swiftpm/Package.resolved | 2 +- .../Diffiable/Section/StatusSection.swift | 1 - Mastodon/Extension/CoreDataStack/Status.swift | 2 +- Mastodon/Generated/Strings.swift | 8 +++ .../UserProvider/UserProviderFacade.swift | 29 ++++++++++ .../Resources/en.lproj/Localizable.strings | 3 + .../APIService/APIService+Status.swift | 55 +++++++++++++++++++ .../API/Mastodon+API+Statuses.swift | 52 +++++++++++++++++- .../Entity/Mastodon+Entity+Status.swift | 2 +- 11 files changed, 154 insertions(+), 9 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index fbc670da6..be86eadef 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -35,6 +35,10 @@ "save_photo_failure": { "title": "Save Photo Failure", "message": "Please enable photo libaray access permission to save photo." + }, + "delete_post": { + "message": "Are you sure you want to delete this post?", + "delete": "DELETE" } }, "controls": { @@ -67,7 +71,8 @@ "report_user": "Report %s", "block_domain": "Block %s", "unblock_domain": "Unblock %s", - "settings": "Settings" + "settings": "Settings", + "delete": "Delete" }, "status": { "user_reblogged": "%s reblogged", diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ff407b6ed..c778b8f90 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -935,7 +935,6 @@ DB98339B25C96DE600AD9700 /* APIService+Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Account.swift"; sourceTree = ""; }; DB9A485B2603010E008B817C /* PHPickerResultLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPickerResultLoader.swift; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; - DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = ""; }; DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = ""; }; @@ -1735,7 +1734,6 @@ DB71FD5125F8CCAA00512AE1 /* APIService+Status.swift */, DB938F1426241FDF00E5B6C1 /* APIService+Thread.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, - DB9A488326034BD7008B817C /* APIService+Status.swift */, 2D61254C262547C200299647 /* APIService+Notification.swift */, DB9A488F26035963008B817C /* APIService+Media.swift */, 2D34D9D026148D9E0081BFC0 /* APIService+Recommend.swift */, diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3295adb41..18cf023e9 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -69,7 +69,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", + "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", "version": "6.2.1" } }, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3994229ee..af4b70490 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -781,7 +781,6 @@ extension StatusSection { } let author = status.authorForUserProvider let isMyself = authenticationBox.userID == author.id - let canReport = !isMyself let isInSameDomain = authenticationBox.domain == author.domainFromAcct let isMuting = (author.mutingBy ?? Set()).map(\.id).contains(authenticationBox.userID) let isBlocking = (author.blockingBy ?? Set()).map(\.id).contains(authenticationBox.userID) diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 1a909285d..73a582937 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -16,7 +16,7 @@ extension Status.Property { id: entity.id, uri: entity.uri, createdAt: entity.createdAt, - content: entity.content, + content: entity.content!, visibility: entity.visibility?.rawValue, sensitive: entity.sensitive ?? false, spoilerText: entity.spoilerText, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 4ec5e7037..81b8f8cc9 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -27,6 +27,12 @@ internal enum L10n { /// Please try again later. internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } + internal enum DeletePost { + /// DELETE + internal static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete") + /// Are you sure you want to delete this post? + internal static let message = L10n.tr("Localizable", "Common.Alerts.DeletePost.Message") + } internal enum DiscardPostContent { /// Confirm discard composed post content. internal static let message = L10n.tr("Localizable", "Common.Alerts.DiscardPostContent.Message") @@ -84,6 +90,8 @@ internal enum L10n { internal static let confirm = L10n.tr("Localizable", "Common.Controls.Actions.Confirm") /// Continue internal static let `continue` = L10n.tr("Localizable", "Common.Controls.Actions.Continue") + /// Delete + internal static let delete = L10n.tr("Localizable", "Common.Controls.Actions.Delete") /// Discard internal static let discard = L10n.tr("Localizable", "Common.Controls.Actions.Discard") /// Done diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 9e20e414a..089cf8ad0 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -300,6 +300,35 @@ extension UserProviderFacade { children.append(shareAction) } + if let status = shareStatus, isMyself { + let deleteAction = UIAction(title: L10n.Common.Controls.Actions.delete, image: UIImage(systemName: "delete.left"), identifier: nil, discoverabilityTitle: nil, attributes: [.destructive], state: .off) { + [weak provider] _ in + guard let provider = provider else { return } + + let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.DeletePost.message, preferredStyle: .alert) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in + } + alertController.addAction(cancelAction) + let deleteAction = UIAlertAction(title: L10n.Common.Alerts.DeletePost.delete, style: .destructive) { _ in + guard let activeMastodonAuthenticationBox = provider.context.authenticationService.activeMastodonAuthenticationBox.value else { return } + provider.context.apiService.deleteStatus(domain: activeMastodonAuthenticationBox.domain, + statusID: status.id, + authorizationBox: activeMastodonAuthenticationBox + ) + .sink { _ in + // do nothing + } receiveValue: { _ in + // do nothing + } + .store(in: &provider.context.disposeBag) + } + alertController.addAction(deleteAction) + provider.present(alertController, animated: true, completion: nil) + + } + children.append(deleteAction) + } + return UIMenu(title: "", options: [], children: children) } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 59f83a5cd..7fc28ca80 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -2,6 +2,8 @@ "Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DeletePost.Delete" = "DELETE"; +"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; "Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. @@ -22,6 +24,7 @@ Please check your internet connection."; "Common.Controls.Actions.Cancel" = "Cancel"; "Common.Controls.Actions.Confirm" = "Confirm"; "Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Delete" = "Delete"; "Common.Controls.Actions.Discard" = "Discard"; "Common.Controls.Actions.Done" = "Done"; "Common.Controls.Actions.Edit" = "Edit"; diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index 08806c886..2b91bbd35 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -88,4 +88,59 @@ extension APIService { .eraseToAnyPublisher() } + func deleteStatus( + domain: String, + statusID: Mastodon.Entity.Status.ID, + authorizationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = authorizationBox.userAuthorization + let query = Mastodon.API.Statuses.DeleteStatusQuery(id: statusID) + return Mastodon.API.Statuses.deleteStatus( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + .flatMap { response -> AnyPublisher, Error> in + return self.backgroundManagedObjectContext.performChanges{ + // fetch old Status + let oldStatus: Status? = { + let request = Status.sortedFetchRequest + request.predicate = Status.predicate(domain: domain, id: response.value.id) + request.fetchLimit = 1 + request.returnsObjectsAsFaults = false + do { + return try self.backgroundManagedObjectContext.fetch(request).first + } catch { + assertionFailure(error.localizedDescription) + return nil + } + }() + if let status = oldStatus { + if let timelineIndex = status.homeTimelineIndexes?.filter({ $0.userID == status.author.id }).first { + self.backgroundManagedObjectContext.delete(timelineIndex) + } + if let poll = status.poll { + self.backgroundManagedObjectContext.delete(poll) + } + if let pollOptions = status.poll?.options { + pollOptions.forEach({ self.backgroundManagedObjectContext.delete($0) }) + } + self.backgroundManagedObjectContext.delete(status) + } + } + .setFailureType(to: Error.self) + .tryMap { result -> Mastodon.Response.Content in + switch result { + case .success: + return response + case .failure(let error): + throw error + } + } + .eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift index bb5a4abfc..e6c8b19d3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses.swift @@ -10,7 +10,7 @@ import Combine extension Mastodon.API.Statuses { - static func viewStatusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + static func statusEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { let pathComponent = "statuses/" + statusID return Mastodon.API.endpointURL(domain: domain).appendingPathComponent(pathComponent) } @@ -38,7 +38,7 @@ extension Mastodon.API.Statuses { authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( - url: viewStatusEndpointURL(domain: domain, statusID: statusID), + url: statusEndpointURL(domain: domain, statusID: statusID), query: nil, authorization: authorization ) @@ -150,6 +150,54 @@ extension Mastodon.API.Statuses { } +extension Mastodon.API.Statuses { + + /// Delete status + /// + /// Delete one of your own statuses. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/5/7 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `DeleteStatusQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func deleteStatus( + session: URLSession, + domain: String, + query: DeleteStatusQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.delete( + url: statusEndpointURL(domain: domain, statusID: query.id), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Status.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct DeleteStatusQuery: Codable, DeleteQuery { + public let id: Mastodon.Entity.Status.ID + + public init( + id: Mastodon.Entity.Status.ID + ) { + self.id = id + } + } +} + extension Mastodon.API.Statuses { static func statusContextEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift index 490429fce..7f8a4fd4e 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Status.swift @@ -26,7 +26,7 @@ extension Mastodon.Entity { public let uri: String public let createdAt: Date public let account: Account - public let content: String + public let content: String? // will be optional when delete status public let visibility: Visibility? public let sensitive: Bool? From faeb8d99efd9bf4b4139c03a3605b65b3e9d6822 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 7 May 2021 18:25:57 +0800 Subject: [PATCH 372/400] feat: display custom emoji for timeline post --- .../CoreData.xcdatamodel/contents | 10 +-- CoreDataStack/Entity/MastodonUser.swift | 13 ++++ CoreDataStack/Entity/Status.swift | 19 +++-- Mastodon.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../Section/ComposeStatusSection.swift | 3 +- .../Diffiable/Section/StatusSection.swift | 14 +++- Mastodon/Extension/ActiveLabel.swift | 20 ++++- Mastodon/Extension/CoreDataStack/Emojis.swift | 36 +++++++++ .../CoreDataStack/MastodonUser.swift | 3 + Mastodon/Extension/CoreDataStack/Status.swift | 3 + Mastodon/Helper/MastodonStatusContent.swift | 74 ++++++++++++------- .../Header/ProfileHeaderViewController.swift | 2 +- .../Header/View/ProfileFieldView.swift | 2 +- .../Settings/SettingsViewController.swift | 2 +- .../Scene/Share/View/Content/StatusView.swift | 14 ++-- .../CoreData/APIService+CoreData+Status.swift | 4 - 17 files changed, 166 insertions(+), 65 deletions(-) create mode 100644 Mastodon/Extension/CoreDataStack/Emojis.swift diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 93d6e4731..6c2d177f8 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -45,7 +45,6 @@ - @@ -102,6 +101,7 @@ + @@ -197,6 +197,7 @@ + @@ -216,7 +217,6 @@ - @@ -267,12 +267,12 @@ - + - + diff --git a/CoreDataStack/Entity/MastodonUser.swift b/CoreDataStack/Entity/MastodonUser.swift index 714b6d0f6..e93d923c0 100644 --- a/CoreDataStack/Entity/MastodonUser.swift +++ b/CoreDataStack/Entity/MastodonUser.swift @@ -25,6 +25,9 @@ final public class MastodonUser: NSManagedObject { @NSManaged public private(set) var headerStatic: String? @NSManaged public private(set) var note: String? @NSManaged public private(set) var url: String? + + @NSManaged public private(set) var emojisData: Data? + @NSManaged public private(set) var statusesCount: NSNumber @NSManaged public private(set) var followingCount: NSNumber @NSManaged public private(set) var followersCount: NSNumber @@ -88,6 +91,8 @@ extension MastodonUser { user.headerStatic = property.headerStatic user.note = property.note user.url = property.url + user.emojisData = property.emojisData + user.statusesCount = NSNumber(value: property.statusesCount) user.followingCount = NSNumber(value: property.followingCount) user.followersCount = NSNumber(value: property.followersCount) @@ -151,6 +156,11 @@ extension MastodonUser { self.url = url } } + public func update(emojisData: Data?) { + if self.emojisData != emojisData { + self.emojisData = emojisData + } + } public func update(statusesCount: Int) { if self.statusesCount.intValue != statusesCount { self.statusesCount = NSNumber(value: statusesCount) @@ -270,6 +280,7 @@ extension MastodonUser { public let headerStatic: String? public let note: String? public let url: String? + public let emojisData: Data? public let statusesCount: Int public let followingCount: Int public let followersCount: Int @@ -292,6 +303,7 @@ extension MastodonUser { headerStatic: String?, note: String?, url: String?, + emojisData: Data?, statusesCount: Int, followingCount: Int, followersCount: Int, @@ -313,6 +325,7 @@ extension MastodonUser { self.headerStatic = headerStatic self.note = note self.url = url + self.emojisData = emojisData self.statusesCount = statusesCount self.followingCount = followingCount self.followersCount = followersCount diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index 1bb71a1db..79214ea42 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -24,6 +24,8 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var spoilerText: String? @NSManaged public private(set) var application: Application? + @NSManaged public private(set) var emojisData: Data? + // Informational @NSManaged public private(set) var reblogsCount: NSNumber @NSManaged public private(set) var favouritesCount: NSNumber @@ -54,7 +56,6 @@ public final class Status: NSManagedObject { // one-to-many relationship @NSManaged public private(set) var reblogFrom: Set? @NSManaged public private(set) var mentions: Set? - @NSManaged public private(set) var emojis: Set? @NSManaged public private(set) var tags: Set? @NSManaged public private(set) var homeTimelineIndexes: Set? @NSManaged public private(set) var mediaAttachments: Set? @@ -77,7 +78,6 @@ extension Status { replyTo: Status?, poll: Poll?, mentions: [Mention]?, - emojis: [Emoji]?, tags: [Tag]?, mediaAttachments: [Attachment]?, favouritedBy: MastodonUser?, @@ -100,6 +100,8 @@ extension Status { status.sensitive = property.sensitive status.spoilerText = property.spoilerText status.application = application + + status.emojisData = property.emojisData status.reblogsCount = property.reblogsCount status.favouritesCount = property.favouritesCount @@ -121,9 +123,6 @@ extension Status { if let mentions = mentions { status.mutableSetValue(forKey: #keyPath(Status.mentions)).addObjects(from: mentions) } - if let emojis = emojis { - status.mutableSetValue(forKey: #keyPath(Status.emojis)).addObjects(from: emojis) - } if let tags = tags { status.mutableSetValue(forKey: #keyPath(Status.tags)).addObjects(from: tags) } @@ -148,6 +147,12 @@ extension Status { return status } + public func update(emojisData: Data?) { + if self.emojisData != emojisData { + self.emojisData = emojisData + } + } + public func update(reblogsCount: NSNumber) { if self.reblogsCount.intValue != reblogsCount.intValue { self.reblogsCount = reblogsCount @@ -248,6 +253,8 @@ extension Status { public let sensitive: Bool public let spoilerText: String? + public let emojisData: Data? + public let reblogsCount: NSNumber public let favouritesCount: NSNumber public let repliesCount: NSNumber? @@ -269,6 +276,7 @@ extension Status { visibility: String?, sensitive: Bool, spoilerText: String?, + emojisData: Data?, reblogsCount: NSNumber, favouritesCount: NSNumber, repliesCount: NSNumber?, @@ -288,6 +296,7 @@ extension Status { self.visibility = visibility self.sensitive = sensitive self.spoilerText = spoilerText + self.emojisData = emojisData self.reblogsCount = reblogsCount self.favouritesCount = favouritesCount self.repliesCount = repliesCount diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index ff407b6ed..2f37b291a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -400,6 +400,7 @@ DBAE3F9E2616E308004B8251 /* APIService+Mute.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */; }; DBAE3FA92617106E004B8251 /* MastodonMetricFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */; }; DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; + DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; @@ -962,6 +963,7 @@ DBAE3F9D2616E308004B8251 /* APIService+Mute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Mute.swift"; sourceTree = ""; }; DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; + DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; @@ -1594,6 +1596,7 @@ DB6D9F6E2635807F008423CD /* Setting.swift */, DB6D9F4826353FD6008423CD /* Subscription.swift */, DB6D9F4F2635761F008423CD /* SubscriptionAlerts.swift */, + DBAFB7342645463500371D5F /* Emojis.swift */, ); path = CoreDataStack; sourceTree = ""; @@ -3199,6 +3202,7 @@ DB6180ED26391C6C0018D199 /* TransitioningMath.swift in Sources */, 2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */, DB51D172262832380062B7A1 /* BlurHashDecode.swift in Sources */, + DBAFB7352645463500371D5F /* Emojis.swift in Sources */, DBCC3B89261454BA0045B23D /* CGImage.swift in Sources */, DBCCC71E25F73297007E1AB6 /* APIService+Reblog.swift in Sources */, DB789A2B25F9F7AB0071ACA0 /* ComposeRepliedToStatusContentCollectionViewCell.swift in Sources */, @@ -3913,8 +3917,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + kind = exactVersion; + version = 5.0.1; }; }; 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = { diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3295adb41..c32d3a6b2 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", "state": { "branch": null, - "revision": "d6cf96e0ca4f2269021bcf8f11381ab57897f84a", - "version": "4.0.0" + "revision": "40e104063d825d1125ef4b8eeb6460eba8a57483", + "version": "5.0.1" } }, { diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 91363ef09..2b7aecae0 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -68,7 +68,8 @@ extension ComposeStatusSection { }() cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct // set text - cell.statusView.activeTextLabel.configure(content: status.content) + //status.emoji + cell.statusView.activeTextLabel.configure(content: status.content, emojiDict: [:]) // set date cell.statusView.dateLabel.text = status.createdAt.shortTimeAgoSinceNow diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3994229ee..d069266f8 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -158,10 +158,11 @@ extension StatusSection { .store(in: &cell.disposeBag) // set name username - cell.statusView.nameLabel.text = { + let nameText: String = { let author = (status.reblog ?? status).author return author.displayName.isEmpty ? author.username : author.displayName }() + cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict) cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct // set avatar if let reblog = status.reblog { @@ -176,7 +177,10 @@ extension StatusSection { } // set text - cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) + cell.statusView.activeTextLabel.configure( + content: (status.reblog ?? status).content, + emojiDict: (status.reblog ?? status).emojiDict + ) // prepare media attachments let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } @@ -569,15 +573,16 @@ extension StatusSection { if status.reblog != nil { cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.reblogIconImage) - cell.statusView.headerInfoLabel.text = { + let headerText: String = { let author = status.author let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() + cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict) } else if status.inReplyToID != nil { cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) - cell.statusView.headerInfoLabel.text = { + let headerText: String = { guard let replyTo = status.replyTo else { return L10n.Common.Controls.Status.userRepliedTo("-") } @@ -585,6 +590,7 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userRepliedTo(name) }() + cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) } else { cell.statusView.headerContainerView.isHidden = true } diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 66452e23e..d929cb571 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -14,6 +14,8 @@ extension ActiveLabel { enum Style { case `default` + case statusHeader + case statusName case profileField } @@ -25,6 +27,7 @@ extension ActiveLabel { mentionColor = Asset.Colors.Label.highlight.color hashtagColor = Asset.Colors.Label.highlight.color URLColor = Asset.Colors.Label.highlight.color + emojiPlaceholderColor = .systemFill #if DEBUG text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." #endif @@ -33,6 +36,14 @@ extension ActiveLabel { case .default: font = .preferredFont(forTextStyle: .body) textColor = Asset.Colors.Label.primary.color + case .statusHeader: + font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) + textColor = Asset.Colors.Label.secondary.color + numberOfLines = 1 + case .statusName: + font = .systemFont(ofSize: 17, weight: .semibold) + textColor = Asset.Colors.Label.primary.color + numberOfLines = 1 case .profileField: font = .preferredFont(forTextStyle: .body) textColor = Asset.Colors.Label.primary.color @@ -44,9 +55,10 @@ extension ActiveLabel { extension ActiveLabel { /// status content - func configure(content: String) { + func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) { activeEntities.removeAll() - if let parseResult = try? MastodonStatusContent.parse(status: content) { + + if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) { text = parseResult.trimmed activeEntities = parseResult.activeEntities } else { @@ -55,8 +67,8 @@ extension ActiveLabel { } /// account note - func configure(note: String) { - configure(content: note) + func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) { + configure(content: note, emojiDict: emojiDict) } } diff --git a/Mastodon/Extension/CoreDataStack/Emojis.swift b/Mastodon/Extension/CoreDataStack/Emojis.swift new file mode 100644 index 000000000..87ae50171 --- /dev/null +++ b/Mastodon/Extension/CoreDataStack/Emojis.swift @@ -0,0 +1,36 @@ +// +// Emojis.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-7. +// + +import Foundation +import MastodonSDK + +protocol EmojiContinaer { + var emojisData: Data? { get } +} + +extension EmojiContinaer { + + static func encode(emojis: [Mastodon.Entity.Emoji]) -> Data? { + return try? JSONEncoder().encode(emojis) + } + + var emojis: [Mastodon.Entity.Emoji]? { + let decoder = JSONDecoder() + return emojisData.flatMap { try? decoder.decode([Mastodon.Entity.Emoji].self, from: $0) } + } + + var emojiDict: MastodonStatusContent.EmojiDict { + var dict = MastodonStatusContent.EmojiDict() + for emoji in emojis ?? [] { + guard let url = URL(string: emoji.url) else { continue } + dict[emoji.shortcode] = url + } + return dict + } + +} + diff --git a/Mastodon/Extension/CoreDataStack/MastodonUser.swift b/Mastodon/Extension/CoreDataStack/MastodonUser.swift index b780f5916..8180b0255 100644 --- a/Mastodon/Extension/CoreDataStack/MastodonUser.swift +++ b/Mastodon/Extension/CoreDataStack/MastodonUser.swift @@ -23,6 +23,7 @@ extension MastodonUser.Property { headerStatic: entity.headerStatic, note: entity.note, url: entity.url, + emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) }, statusesCount: entity.statusesCount, followingCount: entity.followingCount, followersCount: entity.followersCount, @@ -98,3 +99,5 @@ extension MastodonUser { return items } } + +extension MastodonUser: EmojiContinaer { } diff --git a/Mastodon/Extension/CoreDataStack/Status.swift b/Mastodon/Extension/CoreDataStack/Status.swift index 1a909285d..701f9243e 100644 --- a/Mastodon/Extension/CoreDataStack/Status.swift +++ b/Mastodon/Extension/CoreDataStack/Status.swift @@ -20,6 +20,7 @@ extension Status.Property { visibility: entity.visibility?.rawValue, sensitive: entity.sensitive ?? false, spoilerText: entity.spoilerText, + emojisData: entity.emojis.flatMap { Status.encode(emojis: $0) }, reblogsCount: NSNumber(value: entity.reblogsCount), favouritesCount: NSNumber(value: entity.favouritesCount), repliesCount: entity.repliesCount.flatMap { NSNumber(value: $0) }, @@ -86,3 +87,5 @@ extension Status { return items } } + +extension Status: EmojiContinaer { } diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index 5b535b806..284b726a6 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -11,9 +11,21 @@ import ActiveLabel enum MastodonStatusContent { - static func parse(status: String) throws -> MastodonStatusContent.ParseResult { - let status = status.replacingOccurrences(of: "
    ", with: "\n") - let rootNode = try Node.parse(document: status) + typealias EmojiShortcode = String + typealias EmojiDict = [EmojiShortcode: URL] + + static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult { + let document: String = { + var content = content + content = content.replacingOccurrences(of: "
    ", with: "\n") + for (shortcode, url) in emojiDict { + let emojiNode = "\(shortcode)" + let pattern = ":\(shortcode):" + content = content.replacingOccurrences(of: pattern, with: emojiNode) + } + return content + }() + let rootNode = try Node.parse(document: document) let text = String(rootNode.text) var activeEntities: [ActiveEntity] = [] @@ -25,7 +37,7 @@ enum MastodonStatusContent { case .url: guard let href = entity.href else { continue } let text = String(entity.text) - activeEntities.append(ActiveEntity(range: range, type: .url(text, trimmed: entity.hrefEllipsis ?? text, url: href))) + activeEntities.append(ActiveEntity(range: range, type: .url(text, trimmed: entity.hrefEllipsis ?? text, url: href, userInfo: nil))) case .hashtag: var userInfo: [AnyHashable: Any] = [:] entity.href.flatMap { href in @@ -40,30 +52,47 @@ enum MastodonStatusContent { } let mention = String(entity.text).deletingPrefix("@") activeEntities.append(ActiveEntity(range: range, type: .mention(mention, userInfo: userInfo))) - default: + case .emoji: + var userInfo: [AnyHashable: Any] = [:] + guard let href = entity.href else { continue } + userInfo["href"] = href + let emoji = String(entity.text) + activeEntities.append(ActiveEntity(range: range, type: .emoji(emoji, url: href, userInfo: userInfo))) + case .none: continue } } var trimmed = text for activeEntity in activeEntities { - guard case .url = activeEntity.type else { continue } - MastodonStatusContent.trimEntity(status: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities) + MastodonStatusContent.trimEntity(toot: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities) } return ParseResult( - document: status, + document: document, original: text, trimmed: trimmed, - activeEntities: validate(text: trimmed, activeEntities: activeEntities) ? activeEntities : [] + activeEntities: activeEntities ) } - static func trimEntity(status: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) { - guard case let .url(text, trimmed, _, _) = activeEntity.type else { return } + static func trimEntity(toot: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) { + let text: String + let trimmed: String + switch activeEntity.type { + case .url(let _text, let _trimmed, _, _): + text = _text + trimmed = _trimmed + case .emoji(let _text, _, _): + text = _text + trimmed = " " + default: + return + } + guard let index = activeEntities.firstIndex(where: { $0.range == activeEntity.range }) else { return } - guard let range = Range(activeEntity.range, in: status) else { return } - status.replaceSubrange(range, with: trimmed) + guard let range = Range(activeEntity.range, in: toot) else { return } + toot.replaceSubrange(range, with: trimmed) let offset = trimmed.count - text.count activeEntity.range.length += offset @@ -73,19 +102,6 @@ enum MastodonStatusContent { moveActiveEntity.range.location += offset } } - - private static func validate(text: String, activeEntities: [ActiveEntity]) -> Bool { - for activeEntity in activeEntities { - let count = text.utf16.count - let endIndex = activeEntity.range.location + activeEntity.range.length - guard endIndex <= count else { - assertionFailure("Please file issue") - return false - } - } - - return true - } } @@ -106,6 +122,7 @@ extension MastodonStatusContent { } } + extension MastodonStatusContent { class Node { @@ -154,6 +171,10 @@ extension MastodonStatusContent { } } + if _classNames.contains("emoji") { + return .emoji + } + return nil }() self.level = level @@ -257,6 +278,7 @@ extension MastodonStatusContent.Node { case url case mention case hashtag + case emoji } static func entities(in node: MastodonStatusContent.Node) -> [MastodonStatusContent.Node] { diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 3949c3281..0610ae52f 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -175,7 +175,7 @@ extension ProfileHeaderViewController { .receive(on: DispatchQueue.main) .sink { [weak self] isEditing, note, editingNote in guard let self = self else { return } - self.profileHeaderView.bioActiveLabel.configure(note: note ?? "") + self.profileHeaderView.bioActiveLabel.configure(note: note ?? "", emojiDict: [:]) // FIXME: custom emoji self.profileHeaderView.bioTextEditorView.text = editingNote ?? "" } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift index e95697e5c..320a495eb 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileFieldView.swift @@ -20,7 +20,7 @@ final class ProfileFieldView: UIView { let valueActiveLabel: ActiveLabel = { let label = ActiveLabel(style: .profileField) - label.configure(content: "value") + label.configure(content: "value", emojiDict: [:]) return label }() diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 3c4a101a0..e3b186026 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -108,7 +108,7 @@ class SettingsViewController: UIViewController, NeedsDependency { let label = ActiveLabel(style: .default) label.textAlignment = .center - label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).") + label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).", emojiDict: [:]) label.delegate = self view.addArrangedSubview(label) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 20437a8d2..9655940c4 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -11,7 +11,7 @@ import AVKit import ActiveLabel import AlamofireImage -protocol StatusViewDelegate: class { +protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) @@ -69,10 +69,8 @@ final class StatusView: UIView { return label }() - let headerInfoLabel: UILabel = { - let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) - label.textColor = Asset.Colors.Label.secondary.color + let headerInfoLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusHeader) label.text = "Bob reblogged" return label }() @@ -87,10 +85,8 @@ final class StatusView: UIView { }() let avatarStackedContainerButton: AvatarStackContainerButton = AvatarStackContainerButton() - let nameLabel: UILabel = { - let label = UILabel() - label.font = .systemFont(ofSize: 17, weight: .semibold) - label.textColor = Asset.Colors.Label.primary.color + let nameLabel: ActiveLabel = { + let label = ActiveLabel(style: .statusName) label.text = "Alice" return label }() diff --git a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift index 328fa2305..32628a203 100644 --- a/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift +++ b/Mastodon/Service/APIService/CoreData/APIService+CoreData+Status.swift @@ -89,9 +89,6 @@ extension APIService.CoreData { let metions = entity.mentions?.enumerated().compactMap { index, mention -> Mention in Mention.insert(into: managedObjectContext, property: Mention.Property(id: mention.id, username: mention.username, acct: mention.acct, url: mention.url), index: index) } - let emojis = entity.emojis?.compactMap { emoji -> Emoji in - Emoji.insert(into: managedObjectContext, property: Emoji.Property(shortcode: emoji.shortcode, url: emoji.url, staticURL: emoji.staticURL, visibleInPicker: emoji.visibleInPicker, category: emoji.category)) - } let tags = entity.tags?.compactMap { tag -> Tag in let histories = tag.history?.compactMap { history -> History in History.insert(into: managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts)) @@ -121,7 +118,6 @@ extension APIService.CoreData { replyTo: replyTo, poll: poll, mentions: metions, - emojis: emojis, tags: tags, mediaAttachments: mediaAttachments, favouritedBy: (entity.favourited ?? false) ? requestMastodonUser : nil, From 26187d98b7993d9bc0ad8be5d830367c8365f319 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 7 May 2021 18:42:49 +0800 Subject: [PATCH 373/400] chore: renaming interface --- Mastodon/Helper/MastodonStatusContent.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index 284b726a6..0f9dbc6c0 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -65,7 +65,7 @@ enum MastodonStatusContent { var trimmed = text for activeEntity in activeEntities { - MastodonStatusContent.trimEntity(toot: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities) + MastodonStatusContent.trimEntity(status: &trimmed, activeEntity: activeEntity, activeEntities: activeEntities) } return ParseResult( @@ -76,7 +76,7 @@ enum MastodonStatusContent { ) } - static func trimEntity(toot: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) { + static func trimEntity(status: inout String, activeEntity: ActiveEntity, activeEntities: [ActiveEntity]) { let text: String let trimmed: String switch activeEntity.type { @@ -91,8 +91,8 @@ enum MastodonStatusContent { } guard let index = activeEntities.firstIndex(where: { $0.range == activeEntity.range }) else { return } - guard let range = Range(activeEntity.range, in: toot) else { return } - toot.replaceSubrange(range, with: trimmed) + guard let range = Range(activeEntity.range, in: status) else { return } + status.replaceSubrange(range, with: trimmed) let offset = trimmed.count - text.count activeEntity.range.length += offset From b63ae6800bfa0d60109655f0f90290db42aedea6 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 7 May 2021 20:02:28 +0800 Subject: [PATCH 374/400] feat: add a11y for server category picker --- Localization/app.json | 17 +++++++-- .../xcschemes/xcschememanagement.plist | 4 +-- .../Diffiable/Item/CategoryPickerItem.swift | 36 +++++++++++++++++++ .../Section/CategoryPickerSection.swift | 4 +++ Mastodon/Generated/Strings.swift | 26 ++++++++++++++ .../Resources/en.lproj/Localizable.strings | 13 +++++++ .../PickServerCategoriesCell.swift | 16 ++++++++- 7 files changed, 111 insertions(+), 5 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index fbc670da6..4e9d15a8a 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -138,7 +138,20 @@ "title": "Pick a Server,\nany server.", "button": { "category": { - "All": "All" + "all": "All", + "all_accessiblity_description": "Category: All", + "academia": "academia", + "activism": "activism", + "food": "food", + "furry": "furry", + "games": "games", + "general": "general", + "journalism": "journalism", + "lgbt": "lgbt", + "regional": "regional", + "art": "art", + "music": "music", + "tech": "tech" }, "see_less": "See Less", "see_more": "See More" @@ -423,4 +436,4 @@ "text_placeholder": "Type or paste additional comments" } } -} +} \ No newline at end of file diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index f092f9734..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 15 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 14 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/CategoryPickerItem.swift b/Mastodon/Diffiable/Item/CategoryPickerItem.swift index 52bdaf39e..0f2cdcc21 100644 --- a/Mastodon/Diffiable/Item/CategoryPickerItem.swift +++ b/Mastodon/Diffiable/Item/CategoryPickerItem.swift @@ -50,6 +50,42 @@ extension CategoryPickerItem { } } } + + var accessibilityDescription: String { + switch self { + case .all: + return L10n.Scene.ServerPicker.Button.Category.allAccessiblityDescription + case .category(let category): + switch category.category { + case .academia: + return L10n.Scene.ServerPicker.Button.Category.academia + case .activism: + return L10n.Scene.ServerPicker.Button.Category.activism + case .food: + return L10n.Scene.ServerPicker.Button.Category.food + case .furry: + return L10n.Scene.ServerPicker.Button.Category.furry + case .games: + return L10n.Scene.ServerPicker.Button.Category.games + case .general: + return L10n.Scene.ServerPicker.Button.Category.general + case .journalism: + return L10n.Scene.ServerPicker.Button.Category.journalism + case .lgbt: + return L10n.Scene.ServerPicker.Button.Category.lgbt + case .regional: + return L10n.Scene.ServerPicker.Button.Category.regional + case .art: + return L10n.Scene.ServerPicker.Button.Category.art + case .music: + return L10n.Scene.ServerPicker.Button.Category.music + case .tech: + return L10n.Scene.ServerPicker.Button.Category.tech + case ._other: + return "❓" // FIXME: + } + } + } } extension CategoryPickerItem: Equatable { diff --git a/Mastodon/Diffiable/Section/CategoryPickerSection.swift b/Mastodon/Diffiable/Section/CategoryPickerSection.swift index 938683f99..7ab93cc5e 100644 --- a/Mastodon/Diffiable/Section/CategoryPickerSection.swift +++ b/Mastodon/Diffiable/Section/CategoryPickerSection.swift @@ -42,6 +42,10 @@ extension CategoryPickerSection { } } .store(in: &cell.observations) + + cell.isAccessibilityElement = true + cell.accessibilityLabel = item.accessibilityDescription + return cell } } diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 4ec5e7037..e53f9c082 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -638,8 +638,34 @@ internal enum L10n { /// See More internal static let seeMore = L10n.tr("Localizable", "Scene.ServerPicker.Button.SeeMore") internal enum Category { + /// academia + internal static let academia = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Academia") + /// activism + internal static let activism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Activism") /// All internal static let all = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.All") + /// Category: All + internal static let allAccessiblityDescription = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.AllAccessiblityDescription") + /// art + internal static let art = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Art") + /// food + internal static let food = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Food") + /// furry + internal static let furry = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Furry") + /// games + internal static let games = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Games") + /// general + internal static let general = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.General") + /// journalism + internal static let journalism = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Journalism") + /// lgbt + internal static let lgbt = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Lgbt") + /// music + internal static let music = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Music") + /// regional + internal static let regional = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Regional") + /// tech + internal static let tech = L10n.tr("Localizable", "Scene.ServerPicker.Button.Category.Tech") } } internal enum EmptyState { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 59f83a5cd..16552b39e 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -204,7 +204,20 @@ tap the link to confirm your account."; "Scene.Search.Searching.Segment.All" = "All"; "Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; "Scene.Search.Searching.Segment.People" = "People"; +"Scene.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Activism" = "activism"; "Scene.ServerPicker.Button.Category.All" = "All"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Category: All"; +"Scene.ServerPicker.Button.Category.Art" = "art"; +"Scene.ServerPicker.Button.Category.Food" = "food"; +"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Games" = "games"; +"Scene.ServerPicker.Button.Category.General" = "general"; +"Scene.ServerPicker.Button.Category.Journalism" = "journalism"; +"Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; +"Scene.ServerPicker.Button.Category.Music" = "music"; +"Scene.ServerPicker.Button.Category.Regional" = "regional"; +"Scene.ServerPicker.Button.Category.Tech" = "tech"; "Scene.ServerPicker.Button.SeeLess" = "See Less"; "Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 84ee6017c..373a90ddf 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import MastodonSDK -protocol PickServerCategoriesCellDelegate: class { +protocol PickServerCategoriesCellDelegate: AnyObject { func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) } @@ -110,3 +110,17 @@ extension PickServerCategoriesCell: UICollectionViewDelegateFlowLayout { } } + +extension PickServerCategoriesCell { + + override func accessibilityElementCount() -> Int { + guard let diffableDataSource = diffableDataSource else { return 0 } + return diffableDataSource.snapshot().itemIdentifiers.count + } + + override func accessibilityElement(at index: Int) -> Any? { + guard let item = collectionView.cellForItem(at: IndexPath(item: index, section: 0)) else { return nil } + return item + } + +} From d3256f31713f29584393e1ec103500232a249363 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Fri, 7 May 2021 20:36:31 +0800 Subject: [PATCH 375/400] fix: delete reblogFrom inNotifications poll pollOption when delete status --- .../CoreData.xcdatamodel/contents | 11 ++++++----- CoreDataStack/Entity/Status.swift | 2 ++ Localization/app.json | 2 +- Mastodon/Generated/Strings.swift | 2 +- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- Mastodon/Service/APIService/APIService+Status.swift | 9 --------- 6 files changed, 11 insertions(+), 17 deletions(-) diff --git a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents index 93d6e4731..9073b3fbf 100644 --- a/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents +++ b/CoreDataStack/CoreData.xcdatamodeld/CoreData.xcdatamodel/contents @@ -87,7 +87,7 @@ - + @@ -158,7 +158,7 @@ - +
    @@ -218,14 +218,15 @@ - + + - + @@ -279,7 +280,7 @@ - + diff --git a/CoreDataStack/Entity/Status.swift b/CoreDataStack/Entity/Status.swift index 1bb71a1db..cd1ebf3f5 100644 --- a/CoreDataStack/Entity/Status.swift +++ b/CoreDataStack/Entity/Status.swift @@ -60,6 +60,8 @@ public final class Status: NSManagedObject { @NSManaged public private(set) var mediaAttachments: Set? @NSManaged public private(set) var replyFrom: Set? + @NSManaged public private(set) var inNotifications: Set? + @NSManaged public private(set) var updatedAt: Date @NSManaged public private(set) var deletedAt: Date? @NSManaged public private(set) var revealedAt: Date? diff --git a/Localization/app.json b/Localization/app.json index be86eadef..1776c2c0c 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -38,7 +38,7 @@ }, "delete_post": { "message": "Are you sure you want to delete this post?", - "delete": "DELETE" + "delete": "Delete" } }, "controls": { diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 81b8f8cc9..092aa5c45 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -28,7 +28,7 @@ internal enum L10n { internal static let pleaseTryAgainLater = L10n.tr("Localizable", "Common.Alerts.Common.PleaseTryAgainLater") } internal enum DeletePost { - /// DELETE + /// Delete internal static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete") /// Are you sure you want to delete this post? internal static let message = L10n.tr("Localizable", "Common.Alerts.DeletePost.Message") diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 7fc28ca80..d88b00e99 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -2,7 +2,7 @@ "Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; -"Common.Alerts.DeletePost.Delete" = "DELETE"; +"Common.Alerts.DeletePost.Delete" = "Delete"; "Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index 2b91bbd35..c927b05a8 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -117,15 +117,6 @@ extension APIService { } }() if let status = oldStatus { - if let timelineIndex = status.homeTimelineIndexes?.filter({ $0.userID == status.author.id }).first { - self.backgroundManagedObjectContext.delete(timelineIndex) - } - if let poll = status.poll { - self.backgroundManagedObjectContext.delete(poll) - } - if let pollOptions = status.poll?.options { - pollOptions.forEach({ self.backgroundManagedObjectContext.delete($0) }) - } self.backgroundManagedObjectContext.delete(status) } } From 04eeb0100e6ec4f61b07b2a7e95969cfefc09729 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 8 May 2021 10:38:55 +0800 Subject: [PATCH 376/400] fix: safely cancel the listenser for status --- Mastodon/Diffiable/Section/StatusSection.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index af4b70490..76bc91592 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -143,6 +143,21 @@ extension StatusSection { requestUserID: String, statusItemAttribute: Item.StatusAttribute ) { + // safely cancel the listenser when deleted + ManagedObjectObserver.observe(object: status.reblog ?? status) + .receive(on: DispatchQueue.main) + .sink { _ in + // do nothing + } receiveValue: { [weak cell] change in + guard let cell = cell else { return } + guard let changeType = change.changeType else { return } + if case .delete = changeType { + cell.disposeBag.removeAll() + } + } + .store(in: &cell.disposeBag) + + // set header StatusSection.configureHeader(cell: cell, status: status) ManagedObjectObserver.observe(object: status) From 0427beb7d3a55285894ee72f6b6ff0d58238c27f Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 8 May 2021 11:03:34 +0800 Subject: [PATCH 377/400] fix: Using 'class' keyword for protocol inheritance is deprecated --- CoreDataStack/Protocol/Managed.swift | 2 +- Mastodon/Coordinator/NeedsDependency.swift | 2 +- Mastodon/Diffiable/Item/ComposeStatusItem.swift | 2 +- .../ContentOffsetAdjustableTimelineViewControllerDelegate.swift | 2 +- Mastodon/Protocol/DisposeBagCollectable.swift | 2 +- .../ComposeStatusAttachmentCollectionViewCell.swift | 2 +- .../ComposeStatusPollExpiresOptionCollectionViewCell.swift | 2 +- .../ComposeStatusPollOptionAppendEntryCollectionViewCell.swift | 2 +- .../ComposeStatusPollOptionCollectionViewCell.swift | 2 +- Mastodon/Scene/Compose/View/ComposeToolbarView.swift | 2 +- .../HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift | 2 +- .../PickServer/TableViewCell/PickServerCategoriesCell.swift | 2 +- .../Onboarding/PickServer/TableViewCell/PickServerCell.swift | 2 +- .../PickServer/TableViewCell/PickServerSearchCell.swift | 2 +- Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift | 2 +- .../Scene/Profile/Header/View/ProfileStatusDashboardView.swift | 2 +- .../Profile/Segmented/Paging/ProfilePagingViewController.swift | 2 +- .../Settings/View/Cell/SettingsAppearanceTableViewCell.swift | 2 +- .../Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift | 2 +- Mastodon/Scene/Share/View/Container/PlayerContainerView.swift | 2 +- .../Scene/Share/View/Content/ContentWarningOverlayView.swift | 2 +- Mastodon/Scene/Share/View/Content/StatusView.swift | 2 +- .../View/TableviewCell/ThreadReplyLoaderTableViewCell.swift | 2 +- .../View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift | 2 +- .../Share/View/TextField/DeleteBackwardResponseTextField.swift | 2 +- Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift | 2 +- .../MastodonAttachmentService/MastodonAttachmentService.swift | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CoreDataStack/Protocol/Managed.swift b/CoreDataStack/Protocol/Managed.swift index 4811b9c6b..4bdff9c3e 100644 --- a/CoreDataStack/Protocol/Managed.swift +++ b/CoreDataStack/Protocol/Managed.swift @@ -8,7 +8,7 @@ import Foundation import CoreData -public protocol Managed: class, NSFetchRequestResult { +public protocol Managed: AnyObject, NSFetchRequestResult { static var entityName: String { get } static var defaultSortDescriptors: [NSSortDescriptor] { get } } diff --git a/Mastodon/Coordinator/NeedsDependency.swift b/Mastodon/Coordinator/NeedsDependency.swift index 70421a822..d6a24cce3 100644 --- a/Mastodon/Coordinator/NeedsDependency.swift +++ b/Mastodon/Coordinator/NeedsDependency.swift @@ -7,7 +7,7 @@ import UIKit -protocol NeedsDependency: class { +protocol NeedsDependency: AnyObject { var context: AppContext! { get set } var coordinator: SceneCoordinator! { get set } } diff --git a/Mastodon/Diffiable/Item/ComposeStatusItem.swift b/Mastodon/Diffiable/Item/ComposeStatusItem.swift index 88bff36c3..d60a76e82 100644 --- a/Mastodon/Diffiable/Item/ComposeStatusItem.swift +++ b/Mastodon/Diffiable/Item/ComposeStatusItem.swift @@ -50,7 +50,7 @@ extension ComposeStatusItem { } } -protocol ComposePollAttributeDelegate: class { +protocol ComposePollAttributeDelegate: AnyObject { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) } diff --git a/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift b/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift index dbe22c52e..98160eb42 100644 --- a/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift +++ b/Mastodon/Protocol/ContentOffsetAdjustableTimelineViewControllerDelegate.swift @@ -7,7 +7,7 @@ import UIKit -protocol ContentOffsetAdjustableTimelineViewControllerDelegate: class { +protocol ContentOffsetAdjustableTimelineViewControllerDelegate: AnyObject { func navigationBar() -> UINavigationBar? } diff --git a/Mastodon/Protocol/DisposeBagCollectable.swift b/Mastodon/Protocol/DisposeBagCollectable.swift index a8afde9d4..58bfa8576 100644 --- a/Mastodon/Protocol/DisposeBagCollectable.swift +++ b/Mastodon/Protocol/DisposeBagCollectable.swift @@ -8,6 +8,6 @@ import Foundation import Combine -protocol DisposeBagCollectable: class { +protocol DisposeBagCollectable: AnyObject { var disposeBag: Set { get set } } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift index 141a944fd..87fe0efaf 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusAttachmentCollectionViewCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import Combine -protocol ComposeStatusAttachmentCollectionViewCellDelegate: class { +protocol ComposeStatusAttachmentCollectionViewCellDelegate: AnyObject { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift index 0abe94ba0..8347f5641 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollExpiresOptionCollectionViewCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import Combine -protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: class { +protocol ComposeStatusPollExpiresOptionCollectionViewCellDelegate: AnyObject { func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift index 39a12f954..dbe9ef4ad 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionAppendEntryCollectionViewCell.swift @@ -8,7 +8,7 @@ import os.log import UIKit -protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: class { +protocol ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate: AnyObject { func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) } diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 8846e56ed..ab2117f13 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import Combine -protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { +protocol ComposeStatusPollOptionCollectionViewCellDelegate: AnyObject { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 2940217e5..704d5f90b 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -9,7 +9,7 @@ import os.log import UIKit import MastodonSDK -protocol ComposeToolbarViewDelegate: class { +protocol ComposeToolbarViewDelegate: AnyObject { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift index 242715028..91020f12a 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleView.swift @@ -8,7 +8,7 @@ import os.log import UIKit -protocol HomeTimelineNavigationBarTitleViewDelegate: class { +protocol HomeTimelineNavigationBarTitleViewDelegate: AnyObject { func homeTimelineNavigationBarTitleView(_ titleView: HomeTimelineNavigationBarTitleView, buttonDidPressed sender: UIButton) } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift index 84ee6017c..5dd0e4008 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCategoriesCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import MastodonSDK -protocol PickServerCategoriesCellDelegate: class { +protocol PickServerCategoriesCellDelegate: AnyObject { func pickServerCategoriesCell(_ cell: PickServerCategoriesCell, collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 9313aa5cc..3bf65756c 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -12,7 +12,7 @@ import MastodonSDK import AlamofireImage import Kanna -protocol PickServerCellDelegate: class { +protocol PickServerCellDelegate: AnyObject { func pickServerCell(_ cell: PickServerCell, expandButtonPressed button: UIButton) } diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift index fb1be2aad..dc048f67a 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerSearchCell.swift @@ -7,7 +7,7 @@ import UIKit -protocol PickServerSearchCellDelegate: class { +protocol PickServerSearchCellDelegate: AnyObject { func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 3949c3281..afbd17548 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -13,7 +13,7 @@ import AlamofireImage import CropViewController import TwitterTextEditor -protocol ProfileHeaderViewControllerDelegate: class { +protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) func profileHeaderViewController(_ viewController: ProfileHeaderViewController, pageSegmentedControlValueChanged segmentedControl: UISegmentedControl, selectedSegmentIndex index: Int) } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift index 4a95fb22f..38c093d1b 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileStatusDashboardView.swift @@ -8,7 +8,7 @@ import os.log import UIKit -protocol ProfileStatusDashboardViewDelegate: class { +protocol ProfileStatusDashboardViewDelegate: AnyObject { func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, postDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followingDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) func profileStatusDashboardView(_ dashboardView: ProfileStatusDashboardView, followersDashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView) diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift index 568369d66..3b00b1c6f 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -10,7 +10,7 @@ import UIKit import Pageboy import Tabman -protocol ProfilePagingViewControllerDelegate: class { +protocol ProfilePagingViewControllerDelegate: AnyObject { func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index 44a7e7574..5b7c53b02 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -8,7 +8,7 @@ import UIKit import Combine -protocol SettingsAppearanceTableViewCellDelegate: class { +protocol SettingsAppearanceTableViewCellDelegate: AnyObject { func settingsAppearanceCell(_ cell: SettingsAppearanceTableViewCell, didSelectAppearanceMode appearanceMode: SettingsItem.AppearanceMode) } diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift index b4a62635b..86698d840 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsToggleTableViewCell.swift @@ -8,7 +8,7 @@ import UIKit import Combine -protocol SettingsToggleCellDelegate: class { +protocol SettingsToggleCellDelegate: AnyObject { func settingsToggleCell(_ cell: SettingsToggleTableViewCell, switchValueDidChange switch: UISwitch) } diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index f7a8a1546..272abd37e 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -9,7 +9,7 @@ import os.log import AVKit import UIKit -protocol PlayerContainerViewDelegate: class { +protocol PlayerContainerViewDelegate: AnyObject { func playerContainerView(_ playerContainerView: PlayerContainerView, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) } diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index f04a56e9e..ba598a9a0 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -9,7 +9,7 @@ import os.log import Foundation import UIKit -protocol ContentWarningOverlayViewDelegate: class { +protocol ContentWarningOverlayViewDelegate: AnyObject { func contentWarningOverlayViewDidPressed(_ contentWarningOverlayView: ContentWarningOverlayView) } diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 20437a8d2..8903933bd 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -11,7 +11,7 @@ import AVKit import ActiveLabel import AlamofireImage -protocol StatusViewDelegate: class { +protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, headerInfoLabelDidPressed label: UILabel) func statusView(_ statusView: StatusView, avatarButtonDidPressed button: UIButton) func statusView(_ statusView: StatusView, revealContentWarningButtonDidPressed button: UIButton) diff --git a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift index 10ad0c5c8..03359df51 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/ThreadReplyLoaderTableViewCell.swift @@ -9,7 +9,7 @@ import os.log import UIKit import Combine -protocol ThreadReplyLoaderTableViewCellDelegate: class { +protocol ThreadReplyLoaderTableViewCellDelegate: AnyObject { func threadReplyLoaderTableViewCell(_ cell: ThreadReplyLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift index 7438f5bfd..4a0b623ef 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineMiddleLoaderTableViewCell.swift @@ -10,7 +10,7 @@ import CoreData import os.log import UIKit -protocol TimelineMiddleLoaderTableViewCellDelegate: class { +protocol TimelineMiddleLoaderTableViewCellDelegate: AnyObject { func configure(cell: TimelineMiddleLoaderTableViewCell, upperTimelineStatusID: String?, timelineIndexobjectID:NSManagedObjectID?) func timelineMiddleLoaderTableViewCell(_ cell: TimelineMiddleLoaderTableViewCell, loadMoreButtonDidPressed button: UIButton) } diff --git a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift index 21c80dcf8..08c085aa9 100644 --- a/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift +++ b/Mastodon/Scene/Share/View/TextField/DeleteBackwardResponseTextField.swift @@ -7,7 +7,7 @@ import UIKit -protocol DeleteBackwardResponseTextFieldDelegate: class { +protocol DeleteBackwardResponseTextFieldDelegate: AnyObject { func deleteBackwardResponseTextField(_ textField: DeleteBackwardResponseTextField, textBeforeDelete: String?) } diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index a2f57dee2..2ed31abb4 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -8,7 +8,7 @@ import os.log import UIKit -protocol ActionToolbarContainerDelegate: class { +protocol ActionToolbarContainerDelegate: AnyObject { func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, replayButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, reblogButtonDidPressed sender: UIButton) func actionToolbarContainer(_ actionToolbarContainer: ActionToolbarContainer, starButtonDidPressed sender: UIButton) diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift index 3a57a9d98..fd95d2634 100644 --- a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -12,7 +12,7 @@ import Kingfisher import GameplayKit import MastodonSDK -protocol MastodonAttachmentServiceDelegate: class { +protocol MastodonAttachmentServiceDelegate: AnyObject { func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) } From b428eb6e0148a00a65f89aa14395e1058202e831 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Sat, 8 May 2021 14:35:23 +0800 Subject: [PATCH 378/400] feature: add visibility indicator for post --- Mastodon/Diffiable/Section/StatusSection.swift | 14 ++++++++++++++ .../Scene/Compose/View/ComposeToolbarView.swift | 9 +++++++++ .../Scene/Share/View/Content/StatusView.swift | 16 ++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3994229ee..3512eebbc 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -178,6 +178,20 @@ extension StatusSection { // set text cell.statusView.activeTextLabel.configure(content: (status.reblog ?? status).content) + // set visibility + if let visibility = (status.reblog ?? status).visibility { + cell.statusView.updateVisibility(visibility: visibility) + + cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) + .receive(on: DispatchQueue.main) + .sink { [weak cell] isHidden in + cell?.statusView.visibilityImageView.isHidden = !isHidden + } + .store(in: &cell.disposeBag) + } else { + cell.statusView.visibilityImageView.isHidden = true + } + // prepare media attachments let mediaAttachments = Array((status.reblog ?? status).mediaAttachments ?? []).sorted { $0.index.compare($1.index) == .orderedAscending } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 704d5f90b..6aabc4572 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -181,6 +181,15 @@ extension ComposeToolbarView { } } + func imageNameForTimeline() -> String { + switch self { + case .public: return "person.3" + case .unlisted: return "eye.slash" + case .private: return "person.crop.circle.badge.plus" + case .direct: return "at" + } + } + var visibility: Mastodon.Entity.Status.Visibility { switch self { case .public: return .public diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 8903933bd..716f51b57 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -126,6 +126,13 @@ final class StatusView: UIView { return button }() + let visibilityImageView: UIImageView = { + let imageView = UIImageView() + imageView.tintColor = Asset.Colors.Label.secondary.color + imageView.contentMode = .scaleAspectFit + return imageView + }() + let statusContainerStackView = UIStackView() let statusMosaicImageViewContainer = MosaicImageViewContainer() @@ -321,6 +328,10 @@ extension StatusView { authorContainerStackView.addArrangedSubview(revealContentWarningButton) revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) + // visibility ImageView + authorContainerStackView.addArrangedSubview(visibilityImageView) + visibilityImageView.setContentHuggingPriority(.required - 2, for: .horizontal) + authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false authorContainerView.addSubview(authorContainerStackView) NSLayoutConstraint.activate([ @@ -483,6 +494,11 @@ extension StatusView { // TODO: a11y } + func updateVisibility(visibility: String) { + guard let visibility = ComposeToolbarView.VisibilitySelectionType(rawValue: visibility) else { return } + visibilityImageView.image = UIImage(systemName: visibility.imageNameForTimeline(), withConfiguration: UIImage.SymbolConfiguration(pointSize: 13, weight: .regular)) + } + } extension StatusView { From 505c251b37284a8c5b8af9ef7c2a9a1207f34126 Mon Sep 17 00:00:00 2001 From: sunxiaojian Date: Mon, 10 May 2021 15:40:46 +0800 Subject: [PATCH 379/400] fix: move message to title --- Localization/app.json | 4 ++-- Mastodon/Generated/Strings.swift | 6 +++--- Mastodon/Protocol/UserProvider/UserProviderFacade.swift | 4 ++-- Mastodon/Resources/en.lproj/Localizable.strings | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 1776c2c0c..ab888f8f8 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -29,7 +29,7 @@ "confirm": "Sign Out" }, "block_domain": { - "message": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", + "title": "Are you really, really sure you want to block the entire %s? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.", "block_entire_domain": "Block entire domain" }, "save_photo_failure": { @@ -37,7 +37,7 @@ "message": "Please enable photo libaray access permission to save photo." }, "delete_post": { - "message": "Are you sure you want to delete this post?", + "title": "Are you sure you want to delete this post?", "delete": "Delete" } }, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 092aa5c45..6e8c8e39d 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -17,8 +17,8 @@ internal enum L10n { /// Block entire domain internal static let blockEntireDomain = L10n.tr("Localizable", "Common.Alerts.BlockDomain.BlockEntireDomain") /// Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed. - internal static func message(_ p1: Any) -> String { - return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Message", String(describing: p1)) + internal static func title(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Alerts.BlockDomain.Title", String(describing: p1)) } } internal enum Common { @@ -31,7 +31,7 @@ internal enum L10n { /// Delete internal static let delete = L10n.tr("Localizable", "Common.Alerts.DeletePost.Delete") /// Are you sure you want to delete this post? - internal static let message = L10n.tr("Localizable", "Common.Alerts.DeletePost.Message") + internal static let title = L10n.tr("Localizable", "Common.Alerts.DeletePost.Title") } internal enum DiscardPostContent { /// Confirm discard composed post content. diff --git a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift index 089cf8ad0..1f9215a76 100644 --- a/Mastodon/Protocol/UserProvider/UserProviderFacade.swift +++ b/Mastodon/Protocol/UserProvider/UserProviderFacade.swift @@ -252,7 +252,7 @@ extension UserProviderFacade { } else { let blockDomainAction = UIAction(title: L10n.Common.Controls.Actions.blockDomain(mastodonUser.domainFromAcct), image: UIImage(systemName: "nosign"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak provider] _ in guard let provider = provider else { return } - let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.BlockDomain.message(mastodonUser.domainFromAcct), preferredStyle: .alert) + let alertController = UIAlertController(title: L10n.Common.Alerts.BlockDomain.title(mastodonUser.domainFromAcct), message: nil, preferredStyle: .alert) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in } alertController.addAction(cancelAction) @@ -305,7 +305,7 @@ extension UserProviderFacade { [weak provider] _ in guard let provider = provider else { return } - let alertController = UIAlertController(title: "", message: L10n.Common.Alerts.DeletePost.message, preferredStyle: .alert) + let alertController = UIAlertController(title: L10n.Common.Alerts.DeletePost.title, message: nil, preferredStyle: .alert) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .default) { _ in } alertController.addAction(cancelAction) diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index d88b00e99..7b11194a3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -1,9 +1,9 @@ "Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; -"Common.Alerts.BlockDomain.Message" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; "Common.Alerts.Common.PleaseTryAgain" = "Please try again."; "Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; "Common.Alerts.DeletePost.Delete" = "Delete"; -"Common.Alerts.DeletePost.Message" = "Are you sure you want to delete this post?"; +"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?"; "Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; "Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; "Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. From 4901b50d3bc71909b7a745c45d505ace629b3f6b Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 10 May 2021 16:06:00 +0800 Subject: [PATCH 380/400] feat: ignore smart invert for photos --- Mastodon/Protocol/AvatarConfigurableView.swift | 4 ++++ .../MediaPreview/Paging/Image/MediaPreviewImageView.swift | 2 ++ Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift | 5 ++--- .../Settings/View/Cell/SettingsAppearanceTableViewCell.swift | 2 ++ .../Share/View/Container/MosaicImageViewContainer.swift | 3 +++ .../Scene/Share/View/Container/PlayerContainerView.swift | 3 +++ ...stToMediaPreviewViewControllerAnimatedTransitioning.swift | 2 ++ 7 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index 3d2dba802..e33c01278 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -47,6 +47,10 @@ extension AvatarConfigurableView { configurableAvatarButton?.layer.cornerRadius = 0 configurableAvatarButton?.layer.cornerCurve = .circular + // accessibility + configurableAvatarImageView?.accessibilityIgnoresInvertColors = true + configurableAvatarButton?.accessibilityIgnoresInvertColors = true + defer { avatarConfigurableView(self, didFinishConfiguration: configuration) } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift index 0f2ba82fb..0e4aa6d89 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -16,6 +16,8 @@ final class MediaPreviewImageView: UIScrollView { imageView.contentMode = .scaleAspectFit imageView.clipsToBounds = true imageView.isUserInteractionEnabled = true + // accessibility + imageView.accessibilityIgnoresInvertColors = true return imageView }() diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 1e09116d3..0bcc68bfd 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -45,9 +45,8 @@ final class ProfileHeaderView: UIView { imageView.backgroundColor = ProfileHeaderView.bannerImageViewPlaceholderColor imageView.layer.masksToBounds = true imageView.isUserInteractionEnabled = true - // #if DEBUG - // imageView.image = .placeholder(color: .red) - // #endif + // accessibility + imageView.accessibilityIgnoresInvertColors = true return imageView }() let bannerImageViewOverlayView: UIView = { diff --git a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift index 44a7e7574..05bb4d10d 100644 --- a/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift +++ b/Mastodon/Scene/Settings/View/Cell/SettingsAppearanceTableViewCell.swift @@ -15,6 +15,8 @@ protocol SettingsAppearanceTableViewCellDelegate: class { class AppearanceView: UIView { lazy var imageView: UIImageView = { let view = UIImageView() + // accessibility + view.accessibilityIgnoresInvertColors = true return view }() lazy var titleLabel: UILabel = { diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index ea943fb0e..486f97e3c 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -65,6 +65,9 @@ extension MosaicImageViewContainer: ContentWarningOverlayViewDelegate { extension MosaicImageViewContainer { private func _init() { + // accessibility + accessibilityIgnoresInvertColors = true + container.translatesAutoresizingMaskIntoConstraints = false container.axis = .horizontal container.distribution = .fillEqually diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index f7a8a1546..32a4dc147 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -46,6 +46,9 @@ final class PlayerContainerView: UIView { extension PlayerContainerView { private func _init() { + // accessibility + accessibilityIgnoresInvertColors = true + container.translatesAutoresizingMaskIntoConstraints = false addSubview(container) containerHeightLayoutConstraint = container.heightAnchor.constraint(equalToConstant: 162).priority(.required - 1) diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift index 74d82badd..17ba9c660 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaHostToMediaPreviewViewControllerAnimatedTransitioning.swift @@ -73,6 +73,8 @@ extension MediaHostToMediaPreviewViewControllerAnimatedTransitioning { imageView.contentMode = .scaleAspectFill imageView.isUserInteractionEnabled = false imageView.image = transitionItem.image + // accessibility + imageView.accessibilityIgnoresInvertColors = true return imageView }() transitionItem.targetFrame = transitionTargetFrame From 55943db9bc7e2120a9d177c67278918029f6da1f Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 10 May 2021 18:48:04 +0800 Subject: [PATCH 381/400] feat: make dynamic type font adapt accessibility level font size --- .../View/AttachmentContainerView.swift | 2 +- .../NotificationStatusTableViewCell.swift | 4 +- .../NotificationTableViewCell.swift | 4 +- .../TableViewCell/PickServerCell.swift | 16 +++--- .../MastodonRegisterViewController.swift | 13 ++--- .../Register/MastodonRegisterViewModel.swift | 4 +- .../MastodonServerRulesViewController.swift | 5 +- .../Header/View/ProfileHeaderView.swift | 4 +- .../Settings/SettingsViewController.swift | 29 ++++++++-- .../Content/ContentWarningOverlayView.swift | 4 +- .../Scene/Share/View/Content/StatusView.swift | 2 +- .../Share/View/Content/ThreadMetaView.swift | 53 ++++++++++++++----- 12 files changed, 97 insertions(+), 43 deletions(-) diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index cbad76830..eb5f01f41 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -47,7 +47,7 @@ final class AttachmentContainerView: UIView { textView.showsVerticalScrollIndicator = false textView.backgroundColor = .clear textView.textColor = .white - textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20) textView.placeholder = L10n.Scene.Compose.Attachment.descriptionPhoto textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) // force white with alpha for Light/Dark mode textView.returnKeyType = .done diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 7b76dd2f0..5ae5d0136 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -50,7 +50,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { let actionLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFont.preferredFont(forTextStyle: .body) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) label.lineBreakMode = .byTruncatingTail return label }() @@ -58,7 +58,7 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { let nameLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.brandBlue.color - label.font = .systemFont(ofSize: 15, weight: .semibold) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20) label.lineBreakMode = .byTruncatingTail return label }() diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index c049b961e..49d32530b 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -67,7 +67,7 @@ final class NotificationTableViewCell: UITableViewCell { let actionLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.secondary.color - label.font = UIFont.preferredFont(forTextStyle: .body) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) label.lineBreakMode = .byTruncatingTail return label }() @@ -75,7 +75,7 @@ final class NotificationTableViewCell: UITableViewCell { let nameLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.brandBlue.color - label.font = .systemFont(ofSize: 15, weight: .semibold) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 15, weight: .semibold), maximumPointSize: 20) label.lineBreakMode = .byTruncatingTail return label }() diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index 9313aa5cc..85c998b8f 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -34,7 +34,7 @@ class PickServerCell: UITableViewCell { let domainLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false @@ -52,7 +52,7 @@ class PickServerCell: UITableViewCell { let descriptionLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .subheadline) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) label.numberOfLines = 0 label.textColor = Asset.Colors.Label.primary.color label.adjustsFontForContentSizeCategory = true @@ -106,7 +106,7 @@ class PickServerCell: UITableViewCell { let langValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false @@ -116,7 +116,7 @@ class PickServerCell: UITableViewCell { let usersValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false @@ -126,7 +126,7 @@ class PickServerCell: UITableViewCell { let categoryValueLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: UIFont.systemFont(ofSize: 22, weight: .semibold)) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 22, weight: .semibold), maximumPointSize: 27) label.textAlignment = .center label.adjustsFontForContentSizeCategory = true label.translatesAutoresizingMaskIntoConstraints = false @@ -136,7 +136,7 @@ class PickServerCell: UITableViewCell { let langTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = .preferredFont(forTextStyle: .caption2) + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.language label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -147,7 +147,7 @@ class PickServerCell: UITableViewCell { let usersTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = .preferredFont(forTextStyle: .caption2) + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.users label.textAlignment = .center label.adjustsFontForContentSizeCategory = true @@ -158,7 +158,7 @@ class PickServerCell: UITableViewCell { let categoryTitleLabel: UILabel = { let label = UILabel() label.textColor = Asset.Colors.Label.primary.color - label.font = .preferredFont(forTextStyle: .caption2) + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 11, weight: .regular), maximumPointSize: 16) label.text = L10n.Scene.ServerPicker.Label.category label.textAlignment = .center label.adjustsFontForContentSizeCategory = true diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 007012d3c..48401c0c9 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -63,6 +63,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) label.textColor = Asset.Colors.Label.primary.color label.text = L10n.Scene.Register.title + label.numberOfLines = 0 return label }() @@ -99,7 +100,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let domainLabel: UILabel = { let label = UILabel() - label.font = .preferredFont(forTextStyle: .headline) + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) label.textColor = Asset.Colors.Label.primary.color return label }() @@ -113,7 +114,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -136,7 +137,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -153,7 +154,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -178,7 +179,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -208,7 +209,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 309204a9a..85b934a25 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -223,7 +223,7 @@ extension MastodonRegisterViewModel { } static func attributeStringForPassword(validateState: ValidateState) -> NSAttributedString { - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18) let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.checkmarkImage(font: font) @@ -236,7 +236,7 @@ extension MastodonRegisterViewModel { } static func errorPromptAttributedString(for prompt: String) -> NSAttributedString { - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .regular), maximumPointSize: 18) let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.xmarkImage(font: font) diff --git a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift index d8638421a..d865c96ec 100644 --- a/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift +++ b/Mastodon/Scene/Onboarding/ServerRules/MastodonServerRulesViewController.swift @@ -25,6 +25,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: .systemFont(ofSize: 34, weight: .bold)) label.textColor = .label label.text = L10n.Scene.ServerRules.title + label.numberOfLines = 0 return label }() @@ -54,7 +55,7 @@ final class MastodonServerRulesViewController: UIViewController, NeedsDependency private(set) lazy var bottomPromptTextView: UITextView = { let textView = UITextView() - textView.font = .preferredFont(forTextStyle: .body) + textView.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22) textView.textColor = .label textView.isSelectable = true textView.isEditable = false @@ -181,7 +182,7 @@ extension MastodonServerRulesViewController { let str = NSString(string: L10n.Scene.ServerRules.prompt(viewModel.domain)) let termsOfServiceRange = str.range(of: L10n.Scene.ServerRules.termsOfService) let privacyRange = str.range(of: L10n.Scene.ServerRules.privacyPolicy) - let attributeString = NSMutableAttributedString(string: L10n.Scene.ServerRules.prompt(viewModel.domain), attributes: [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .body), NSAttributedString.Key.foregroundColor: UIColor.label]) + let attributeString = NSMutableAttributedString(string: L10n.Scene.ServerRules.prompt(viewModel.domain), attributes: [NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular), maximumPointSize: 22), NSAttributedString.Key.foregroundColor: UIColor.label]) attributeString.addAttribute(.link, value: Mastodon.API.serverRulesURL(domain: viewModel.domain), range: termsOfServiceRange) attributeString.addAttribute(.link, value: Mastodon.API.privacyURL(domain: viewModel.domain), range: privacyRange) let linkAttributes = [NSAttributedString.Key.foregroundColor:linkColor] diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 0bcc68bfd..e37427e32 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -100,7 +100,7 @@ final class ProfileHeaderView: UIView { let nameTextField: UITextField = { let textField = UITextField() - textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold)) + textField.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold), maximumPointSize: 28) textField.textColor = .white textField.text = "Alice" textField.autocorrectionType = .no @@ -111,7 +111,7 @@ final class ProfileHeaderView: UIView { let usernameLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular)) + label.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular), maximumPointSize: 20) label.adjustsFontSizeToFitWidth = true label.minimumScaleFactor = 0.5 label.textColor = Asset.Scene.Profile.Banner.usernameGray.color diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 3c4a101a0..39bafdf49 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -51,21 +51,27 @@ class SettingsViewController: UIViewController, NeedsDependency { return menu } - private(set) lazy var notifySectionHeader: UIView = { + private let notifySectionHeaderStackView: UIStackView = { let view = UIStackView() view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true - //view.layoutMargins = UIEdgeInsets(top: 15, left: 4, bottom: 5, right: 4) view.axis = .horizontal view.alignment = .fill view.distribution = .equalSpacing view.spacing = 4 + return view + }() + + private(set) lazy var notifySectionHeader: UIView = { + let view = notifySectionHeaderStackView let notifyLabel = UILabel() notifyLabel.translatesAutoresizingMaskIntoConstraints = false - notifyLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) + notifyLabel.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) notifyLabel.textColor = Asset.Colors.Label.primary.color notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title + // accessibility + notifyLabel.numberOfLines = 0 view.addArrangedSubview(notifyLabel) view.addArrangedSubview(whoButton) return view @@ -138,7 +144,22 @@ class SettingsViewController: UIViewController, NeedsDependency { } } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateSectionHeaderStackViewLayout() + } + + // MAKR: - Private methods + private func updateSectionHeaderStackViewLayout() { + if traitCollection.preferredContentSizeCategory < .accessibilityMedium { + notifySectionHeaderStackView.axis = .horizontal + } else { + notifySectionHeaderStackView.axis = .vertical + } + } + private func bindViewModel() { self.whoButton.setTitle(viewModel.setting.value.activeSubscription?.policy.title, for: .normal) viewModel.setting @@ -173,6 +194,8 @@ class SettingsViewController: UIViewController, NeedsDependency { tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) setupTableView() + + updateSectionHeaderStackViewLayout() } private func setupNavigation() { diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index f04a56e9e..6cf62783f 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -36,7 +36,7 @@ class ContentWarningOverlayView: UIView { }() let blurContentWarningTitleLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17)) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17), maximumPointSize: 23) label.text = L10n.Common.Controls.Status.mediaContentWarning label.textColor = Asset.Colors.Label.primary.color label.textAlignment = .center @@ -44,7 +44,7 @@ class ContentWarningOverlayView: UIView { }() let blurContentWarningLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15)) + label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15), maximumPointSize: 20) label.text = L10n.Common.Controls.Status.mediaContentWarning label.textColor = Asset.Colors.Label.secondary.color label.textAlignment = .center diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 20437a8d2..179373830 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -71,7 +71,7 @@ final class StatusView: UIView { let headerInfoLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium)) + label.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .medium), maximumPointSize: 17) label.textColor = Asset.Colors.Label.secondary.color label.text = "Bob reblogged" return label diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift index 16d1b04a6..e84939d32 100644 --- a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -34,6 +34,14 @@ final class ThreadMetaView: UIView { return button }() + let containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 20 + return stackView + }() + let actionButtonStackView = UIStackView() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -48,27 +56,48 @@ final class ThreadMetaView: UIView { extension ThreadMetaView { private func _init() { - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.spacing = 20 - stackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(stackView) + containerStackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerStackView) NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), - stackView.leadingAnchor.constraint(equalTo: leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12), + containerStackView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 12), ]) - stackView.addArrangedSubview(dateLabel) - stackView.addArrangedSubview(reblogButton) - stackView.addArrangedSubview(favoriteButton) + containerStackView.addArrangedSubview(dateLabel) + containerStackView.addArrangedSubview(actionButtonStackView) + + actionButtonStackView.axis = .horizontal + actionButtonStackView.addArrangedSubview(reblogButton) + actionButtonStackView.addArrangedSubview(favoriteButton) dateLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) reblogButton.setContentHuggingPriority(.required - 2, for: .horizontal) favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) + + updateContainerLayout() } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateContainerLayout() + } + + private func updateContainerLayout() { + if traitCollection.preferredContentSizeCategory < .accessibilityMedium { + containerStackView.axis = .horizontal + containerStackView.spacing = 20 + dateLabel.numberOfLines = 1 + } else { + containerStackView.axis = .vertical + containerStackView.spacing = 4 + dateLabel.numberOfLines = 0 + } + } + } #if canImport(SwiftUI) && DEBUG From 87d93f3c9d2199972ce0b3f8cab5b1822619aaf5 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:10:05 +0800 Subject: [PATCH 382/400] fix: remove entity raise crash issue --- Mastodon/Diffiable/Section/StatusSection.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index b5f9869e2..1ed5f0d42 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -449,7 +449,8 @@ extension StatusSection { } receiveValue: { [weak dependency, weak cell] change in guard let dependency = dependency else { return } guard case .update(let object) = change.changeType, - let status = object as? Status else { return } + let status = object as? Status, + !status.isDeleted else { return } guard let statusTableViewCell = cell as? StatusTableViewCell else { return } StatusSection.configureActionToolBar( cell: statusTableViewCell, @@ -648,7 +649,7 @@ extension StatusSection { .assertNoFailure() ) .receive(on: DispatchQueue.main) - .sink { [weak dependency, weak cell] _,change in + .sink { [weak dependency, weak cell] _, change in guard let cell = cell else { return } guard let dependency = dependency else { return } switch change.changeType { From 1eed6e49864a8f9d566552d837f12c7d07cbb65e Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:11:00 +0800 Subject: [PATCH 383/400] fix: media photo preview transition top and bottom bar missing mask issue --- .../mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist | 4 ++-- .../Transition/MediaPreview/MediaPreviewTransitionItem.swift | 2 ++ .../MediaPreview/MediaPreviewableViewController.swift | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 326857269..f092f9734 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 14 + 15 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 15 + 14 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift index 47fdd215d..7024d3056 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewTransitionItem.swift @@ -30,6 +30,8 @@ class MediaPreviewTransitionItem: Identifiable { var snapshotRaw: UIView? var snapshotTransitioning: UIView? var touchOffset: CGVector = CGVector.zero + var interactiveTransitionMaskView: UIView? + var interactiveTransitionMaskLayer: CAShapeLayer? init(id: UUID = UUID(), source: Source, previewableViewController: MediaPreviewableViewController) { self.id = id diff --git a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift index 8029c09da..63cf10c3e 100644 --- a/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift +++ b/Mastodon/Scene/Transition/MediaPreview/MediaPreviewableViewController.swift @@ -7,7 +7,7 @@ import UIKit -protocol MediaPreviewableViewController: AnyObject { +protocol MediaPreviewableViewController: UIViewController { var mediaPreviewTransitionController: MediaPreviewTransitionController { get } func sourceFrame(transitionItem: MediaPreviewTransitionItem, index: Int) -> CGRect? } From a349a76bdd9832aa49e584845a27db29e2768d9f Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:22:29 +0800 Subject: [PATCH 384/400] fix: register error prompt label dynamic type font issue --- .../MastodonRegisterViewController.swift | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index 48401c0c9..b6214d4e7 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -16,6 +16,9 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O static let avatarImageMaxSizeInPixel = CGSize(width: 400, height: 400) + static let textFieldLabelFont = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + static let errorPromptLabelFont = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .semibold), maximumPointSize: 18) + var disposeBag = Set() weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -100,7 +103,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let domainLabel: UILabel = { let label = UILabel() - label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22) + label.font = MastodonRegisterViewController.textFieldLabelFont label.textColor = Asset.Colors.Label.primary.color return label }() @@ -114,7 +117,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -125,7 +128,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let usernameErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.danger.color - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = MastodonRegisterViewController.errorPromptLabelFont return label }() @@ -137,7 +140,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -154,7 +157,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -165,7 +168,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let emailErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.danger.color - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = MastodonRegisterViewController.errorPromptLabelFont return label }() @@ -179,7 +182,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -196,7 +199,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let passwordErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.danger.color - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = MastodonRegisterViewController.errorPromptLabelFont return label }() @@ -209,7 +212,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O textField.textColor = Asset.Colors.Label.primary.color textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Invite.registrationUserInviteRequest, attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.Label.secondary.color, - NSAttributedString.Key.font: UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 22)]) + NSAttributedString.Key.font: MastodonRegisterViewController.textFieldLabelFont]) textField.borderStyle = UITextField.BorderStyle.roundedRect let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) textField.leftView = paddingView @@ -220,7 +223,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let reasonErrorPromptLabel: UILabel = { let label = UILabel() let color = Asset.Colors.danger.color - let font = UIFont.preferredFont(forTextStyle: .caption1) + let font = MastodonRegisterViewController.errorPromptLabelFont return label }() From 940e69456dee68a604c5ee2757dc0574f003ff45 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:26:42 +0800 Subject: [PATCH 385/400] fix: meta label in post thread scene layout issue --- Mastodon/Scene/Share/View/Content/ThreadMetaView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift index e84939d32..a4b0886d6 100644 --- a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -70,6 +70,7 @@ extension ThreadMetaView { containerStackView.addArrangedSubview(actionButtonStackView) actionButtonStackView.axis = .horizontal + actionButtonStackView.spacing = 20 actionButtonStackView.addArrangedSubview(reblogButton) actionButtonStackView.addArrangedSubview(favoriteButton) From 1d552d38f584dda863f5116b3dbb908faf63e051 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:44:23 +0800 Subject: [PATCH 386/400] fix: visibility icon layout issue --- Mastodon/Scene/Share/View/Content/StatusView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index c06c956e0..5ebdd17c5 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -295,7 +295,7 @@ extension StatusView { authorMetaContainerStackView.axis = .vertical authorMetaContainerStackView.spacing = 4 - // title container: [display name | "·" | date] + // title container: [display name | "·" | date | padding | visibility] let titleContainerStackView = UIStackView() authorMetaContainerStackView.addArrangedSubview(titleContainerStackView) titleContainerStackView.axis = .horizontal @@ -308,12 +308,15 @@ extension StatusView { titleContainerStackView.alignment = .firstBaseline titleContainerStackView.addArrangedSubview(nameTrialingDotLabel) titleContainerStackView.addArrangedSubview(dateLabel) + titleContainerStackView.addArrangedSubview(UIView()) // padding + titleContainerStackView.addArrangedSubview(visibilityImageView) nameLabel.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) nameTrialingDotLabel.setContentHuggingPriority(.defaultHigh + 2, for: .horizontal) nameTrialingDotLabel.setContentCompressionResistancePriority(.required - 2, for: .horizontal) dateLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) dateLabel.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - + visibilityImageView.setContentHuggingPriority(.defaultHigh + 3, for: .horizontal) + // subtitle container: [username] let subtitleContainerStackView = UIStackView() authorMetaContainerStackView.addArrangedSubview(subtitleContainerStackView) @@ -324,10 +327,6 @@ extension StatusView { authorContainerStackView.addArrangedSubview(revealContentWarningButton) revealContentWarningButton.setContentHuggingPriority(.required - 2, for: .horizontal) - // visibility ImageView - authorContainerStackView.addArrangedSubview(visibilityImageView) - visibilityImageView.setContentHuggingPriority(.required - 2, for: .horizontal) - authorContainerStackView.translatesAutoresizingMaskIntoConstraints = false authorContainerView.addSubview(authorContainerStackView) NSLayoutConstraint.activate([ From c11b0bec1ee59f4d242110260caaabd73400a06c Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 16:44:41 +0800 Subject: [PATCH 387/400] fix: profile timeline loader missing when paging issue --- .../Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 8e6f1314f..7e4ec8728 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -32,6 +32,7 @@ extension UserTimelineViewModel { // set empty section to make update animation top-to-bottom style var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) + snapshot.appendItems([.bottomLoader], toSection: .main) diffableDataSource?.apply(snapshot) } From 108c6af575ee84c12d5ade94eaaad14a491cb459 Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 11 May 2021 17:07:08 +0800 Subject: [PATCH 388/400] fix: notification setting label accessibility layout issue --- .../Scene/Settings/SettingsViewController.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Mastodon/Scene/Settings/SettingsViewController.swift b/Mastodon/Scene/Settings/SettingsViewController.swift index 3d6ebc470..1c69ef6c5 100644 --- a/Mastodon/Scene/Settings/SettingsViewController.swift +++ b/Mastodon/Scene/Settings/SettingsViewController.swift @@ -56,24 +56,22 @@ class SettingsViewController: UIViewController, NeedsDependency { view.translatesAutoresizingMaskIntoConstraints = false view.isLayoutMarginsRelativeArrangement = true view.axis = .horizontal - view.alignment = .fill - view.distribution = .equalSpacing view.spacing = 4 return view }() + let notifyLabel = UILabel() private(set) lazy var notifySectionHeader: UIView = { let view = notifySectionHeaderStackView - - let notifyLabel = UILabel() notifyLabel.translatesAutoresizingMaskIntoConstraints = false + notifyLabel.adjustsFontForContentSizeCategory = true notifyLabel.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) notifyLabel.textColor = Asset.Colors.Label.primary.color notifyLabel.text = L10n.Scene.Settings.Section.Notifications.Trigger.title - // accessibility - notifyLabel.numberOfLines = 0 view.addArrangedSubview(notifyLabel) view.addArrangedSubview(whoButton) + whoButton.setContentHuggingPriority(.defaultHigh + 1, for: .horizontal) + whoButton.setContentHuggingPriority(.defaultHigh + 1, for: .vertical) return view }() @@ -83,6 +81,7 @@ class SettingsViewController: UIViewController, NeedsDependency { whoButton.showsMenuAsPrimaryAction = true whoButton.setBackgroundColor(Asset.Colors.battleshipGrey.color, for: .normal) whoButton.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + whoButton.titleLabel?.adjustsFontForContentSizeCategory = true whoButton.titleLabel?.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: UIFont.systemFont(ofSize: 20, weight: .semibold)) whoButton.contentEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) whoButton.layer.cornerRadius = 10 @@ -113,6 +112,7 @@ class SettingsViewController: UIViewController, NeedsDependency { view.alignment = .center let label = ActiveLabel(style: .default) + label.adjustsFontForContentSizeCategory = true label.textAlignment = .center label.configure(content: "Mastodon is open source software. You can contribute or report issues on GitHub at tootsuite/mastodon (v3.3.0).", emojiDict: [:]) label.delegate = self @@ -153,10 +153,13 @@ class SettingsViewController: UIViewController, NeedsDependency { // MAKR: - Private methods private func updateSectionHeaderStackViewLayout() { + // accessibility if traitCollection.preferredContentSizeCategory < .accessibilityMedium { notifySectionHeaderStackView.axis = .horizontal + notifyLabel.numberOfLines = 1 } else { notifySectionHeaderStackView.axis = .vertical + notifyLabel.numberOfLines = 0 } } From 6ba6598b96b05ef095cc759c80f5eaabfcfec619 Mon Sep 17 00:00:00 2001 From: CMK Date: Wed, 12 May 2021 18:26:53 +0800 Subject: [PATCH 389/400] feat: add accessibility supports for timeline --- Localization/app.json | 21 ++++ Mastodon.xcodeproj/project.pbxproj | 2 + .../Diffiable/Section/StatusSection.swift | 40 ++++++- Mastodon/Extension/ActiveLabel.swift | 109 ++++++++++++++++++ Mastodon/Generated/Strings.swift | 42 +++++++ ...Provider+StatusTableViewCellDelegate.swift | 7 +- .../Resources/en.lproj/Localizable.strings | 15 +++ .../MediaPreview/MediaPreviewViewModel.swift | 6 +- .../Paging/Image/MediaPreviewImageView.swift | 1 + .../MediaPreviewImageViewController.swift | 1 + .../Image/MediaPreviewImageViewModel.swift | 4 + .../Container/MosaicImageViewContainer.swift | 1 + .../Content/ContentWarningOverlayView.swift | 3 + .../Scene/Share/View/Content/StatusView.swift | 2 + .../Share/View/Content/ThreadMetaView.swift | 4 + .../TableviewCell/StatusTableViewCell.swift | 8 ++ .../View/ToolBar/ActionToolBarContainer.swift | 13 +++ .../ViewModel/MosaicImageViewModel.swift | 4 +- 18 files changed, 275 insertions(+), 8 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 3528153ca..98e9accd4 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -93,6 +93,22 @@ }, "time_left": "%s left", "closed": "Closed" + }, + "actions": { + "reply": "Reply", + "reblog": "Reblog", + "unreblog": "Unreblog", + "favorite": "Favorite", + "unfavorite": "Unfavorite", + "menu": "Menu" + }, + "tag": { + "url": "URL", + "mention": "Mention", + "link": "Link", + "hashtag": "Hashtag", + "email": "Email", + "emoji": "Emoji" } }, "firendship": { @@ -125,6 +141,11 @@ "blocked_warning": "You can’t view Artbot’s profile\n until they unblock you.", "suspended_warning": "This account has been suspended.", "user_suspended_warning": "%s's account has been suspended." + }, + "accessibility": { + "count_replies": "%s replies", + "count_reblogs": "%s reblogs", + "count_favorites": "%s favorites" } } }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 506e32dea..0abd7ad95 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -756,6 +756,7 @@ DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; + DB0F814C264BBDD900F2A12B /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ActiveLabel.swift; path = ../ActiveLabel.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; @@ -1645,6 +1646,7 @@ children = ( DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, + DB0F814C264BBDD900F2A12B /* ActiveLabel.swift */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index ef5ccb502..e7ca8182a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -58,6 +58,7 @@ extension StatusSection { ) } cell.delegate = statusTableViewCellDelegate + cell.isAccessibilityElement = true return cell case .status(let objectID, let attribute), .root(let objectID, let attribute), @@ -97,7 +98,22 @@ extension StatusSection { } } cell.delegate = statusTableViewCellDelegate - + switch item { + case .root: + cell.statusView.activeTextLabel.isAccessibilityElement = false + var accessibilityElements: [Any] = [] + accessibilityElements.append(cell.statusView.nameLabel) + accessibilityElements.append(cell.statusView.dateLabel) + accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements()) + accessibilityElements.append(contentsOf: cell.statusView.statusMosaicImageViewContainer.imageViews) + accessibilityElements.append(cell.statusView.playerContainerView) + accessibilityElements.append(cell.statusView.actionToolbarContainer) + accessibilityElements.append(cell.threadMetaView) + cell.accessibilityElements = accessibilityElements + default: + cell.isAccessibilityElement = true + cell.accessibilityElements = nil + } return cell case .leafBottomLoader: let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ThreadReplyLoaderTableViewCell.self), for: indexPath) as! ThreadReplyLoaderTableViewCell @@ -179,6 +195,7 @@ extension StatusSection { }() cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict) cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct + // set avatar if let reblog = status.reblog { cell.statusView.avatarButton.isHidden = true @@ -196,6 +213,7 @@ extension StatusSection { content: (status.reblog ?? status).content, emojiDict: (status.reblog ?? status).emojiDict ) + cell.statusView.activeTextLabel.accessibilityLanguage = (status.reblog ?? status).language // set visibility if let visibility = (status.reblog ?? status).visibility { @@ -275,6 +293,7 @@ extension StatusSection { break } } + imageView.accessibilityLabel = meta.altText Publishers.CombineLatest( statusItemAttribute.isImageLoaded, statusItemAttribute.isRevealing @@ -452,6 +471,7 @@ extension StatusSection { .sink { [weak cell] _ in guard let cell = cell else { return } cell.statusView.dateLabel.text = createdAt.shortTimeAgoSinceNow + cell.statusView.dateLabel.accessibilityLabel = createdAt.timeAgoSinceNow } .store(in: &cell.disposeBag) @@ -572,6 +592,7 @@ extension StatusSection { formatter.timeStyle = .short return formatter.string(from: status.createdAt) }() + cell.threadMetaView.dateLabel.accessibilityLabel = DateFormatter.localizedString(from: status.createdAt, dateStyle: .medium, timeStyle: .short) let reblogCountTitle: String = { let count = status.reblogsCount.intValue if count > 1 { @@ -609,6 +630,7 @@ extension StatusSection { return L10n.Common.Controls.Status.userReblogged(name) }() cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict) + cell.statusView.headerInfoLabel.isAccessibilityElement = true } else if status.inReplyToID != nil { cell.statusView.headerContainerView.isHidden = false cell.statusView.headerIconLabel.attributedText = StatusView.iconAttributedString(image: StatusView.replyIconImage) @@ -621,8 +643,10 @@ extension StatusSection { return L10n.Common.Controls.Status.userRepliedTo(name) }() cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) + cell.statusView.headerInfoLabel.isAccessibilityElement = true } else { cell.statusView.headerContainerView.isHidden = true + cell.statusView.headerInfoLabel.isAccessibilityElement = false } } @@ -640,6 +664,9 @@ extension StatusSection { return StatusSection.formattedNumberTitleForActionButton(count) }() cell.statusView.actionToolbarContainer.replyButton.setTitle(replyCountTitle, for: .normal) + cell.statusView.actionToolbarContainer.replyButton.accessibilityValue = status.repliesCount.flatMap { + L10n.Common.Controls.Timeline.Accessibility.countReplies($0.intValue) + } ?? nil // set reblog let isReblogged = status.rebloggedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let reblogCountTitle: String = { @@ -648,6 +675,11 @@ extension StatusSection { }() cell.statusView.actionToolbarContainer.reblogButton.setTitle(reblogCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isReblogButtonHighlight = isReblogged + cell.statusView.actionToolbarContainer.reblogButton.accessibilityLabel = isReblogged ? L10n.Common.Controls.Status.Actions.unreblog : L10n.Common.Controls.Status.Actions.reblog + cell.statusView.actionToolbarContainer.reblogButton.accessibilityValue = { + guard status.reblogsCount.intValue > 0 else { return nil } + return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.reblogsCount.intValue) + }() // set like let isLike = status.favouritedBy.flatMap { $0.contains(where: { $0.id == requestUserID }) } ?? false let favoriteCountTitle: String = { @@ -656,7 +688,11 @@ extension StatusSection { }() cell.statusView.actionToolbarContainer.favoriteButton.setTitle(favoriteCountTitle, for: .normal) cell.statusView.actionToolbarContainer.isFavoriteButtonHighlight = isLike - + cell.statusView.actionToolbarContainer.favoriteButton.accessibilityLabel = isLike ? L10n.Common.Controls.Status.Actions.unfavorite : L10n.Common.Controls.Status.Actions.favorite + cell.statusView.actionToolbarContainer.favoriteButton.accessibilityValue = { + guard status.favouritesCount.intValue > 0 else { return nil } + return L10n.Common.Controls.Timeline.Accessibility.countReblogs(status.favouritesCount.intValue) + }() Publishers.CombineLatest( dependency.context.blockDomainService.blockedDomains, ManagedObjectObserver.observe(object: status.authorForUserProvider) diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index a26838850..4e1d855b1 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -32,6 +32,8 @@ extension ActiveLabel { text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." #endif + accessibilityContainerType = .semanticGroup + switch style { case .default: font = .preferredFont(forTextStyle: .body) @@ -61,8 +63,10 @@ extension ActiveLabel { if let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) { text = parseResult.trimmed activeEntities = parseResult.activeEntities + accessibilityLabel = parseResult.original } else { text = "" + accessibilityLabel = nil } } @@ -79,5 +83,110 @@ extension ActiveLabel { let parseResult = MastodonField.parse(field: field) text = parseResult.value activeEntities = parseResult.activeEntities + accessibilityLabel = parseResult.value } } + +extension ActiveEntity { + + var accessibilityLabelDescription: String { + switch self.type { + case .email: return L10n.Common.Controls.Status.Tag.email + case .hashtag: return L10n.Common.Controls.Status.Tag.hashtag + case .mention: return L10n.Common.Controls.Status.Tag.mention + case .url: return L10n.Common.Controls.Status.Tag.url + case .emoji: return L10n.Common.Controls.Status.Tag.emoji + } + } + + var accessibilityValueDescription: String { + switch self.type { + case .email(let text, _): return text + case .hashtag(let text, _): return text + case .mention(let text, _): return text + case .url(_, let trimmed, _, _): return trimmed + case .emoji(let text, _, _): return text + } + } + + func accessibilityElement(in accessibilityContainer: Any) -> ActiveLabelAccessibilityElement? { + if case .emoji = self.type { + return nil + } + + let element = ActiveLabelAccessibilityElement(accessibilityContainer: accessibilityContainer) + element.accessibilityTraits = .button + element.accessibilityLabel = accessibilityLabelDescription + element.accessibilityValue = accessibilityValueDescription + return element + } +} + +final class ActiveLabelAccessibilityElement: UIAccessibilityElement { + var index: Int! +} + +// MARK: - UIAccessibilityContainer +extension ActiveLabel { + + func createAccessibilityElements() -> [UIAccessibilityElement] { + var elements: [UIAccessibilityElement] = [] + + let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) + element.accessibilityTraits = .staticText + element.accessibilityLabel = accessibilityLabel + element.accessibilityFrame = superview!.convert(frame, to: nil) + element.accessibilityLanguage = accessibilityLanguage + elements.append(element) + + for eneity in activeEntities { + guard let element = eneity.accessibilityElement(in: self) else { continue } + var glyphRange = NSRange() + layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) + let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) + element.accessibilityFrame = self.convert(rect, to: nil) + element.accessibilityContainer = self + elements.append(element) + } + + return elements + } + +// public override func accessibilityElementCount() -> Int { +// return 1 + activeEntities.count +// } +// +// public override func accessibilityElement(at index: Int) -> Any? { +// if index == 0 { +// let element = ActiveLabelAccessibilityElement(accessibilityContainer: self) +// element.accessibilityTraits = .staticText +// element.accessibilityLabel = accessibilityLabel +// element.accessibilityFrame = superview!.convert(frame, to: nil) +// element.index = index +// return element +// } +// +// let index = index - 1 +// guard index < activeEntities.count else { return nil } +// let eneity = activeEntities[index] +// guard let element = eneity.accessibilityElement(in: self) else { return nil } +// +// var glyphRange = NSRange() +// layoutManager.characterRange(forGlyphRange: eneity.range, actualGlyphRange: &glyphRange) +// let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) +// element.accessibilityFrame = self.convert(rect, to: nil) +// element.accessibilityContainer = self +// +// return element +// } +// +// public override func index(ofAccessibilityElement element: Any) -> Int { +// guard let element = element as? ActiveLabelAccessibilityElement, +// let index = element.index else { +// return NSNotFound +// } +// +// return index +// } + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index a4457b2f5..3785a79d9 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -208,6 +208,20 @@ internal enum L10n { internal static func userRepliedTo(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserRepliedTo", String(describing: p1)) } + internal enum Actions { + /// Favorite + internal static let favorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Favorite") + /// Menu + internal static let menu = L10n.tr("Localizable", "Common.Controls.Status.Actions.Menu") + /// Reblog + internal static let reblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reblog") + /// Reply + internal static let reply = L10n.tr("Localizable", "Common.Controls.Status.Actions.Reply") + /// Unfavorite + internal static let unfavorite = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unfavorite") + /// Unreblog + internal static let unreblog = L10n.tr("Localizable", "Common.Controls.Status.Actions.Unreblog") + } internal enum Poll { /// Closed internal static let closed = L10n.tr("Localizable", "Common.Controls.Status.Poll.Closed") @@ -238,8 +252,36 @@ internal enum L10n { } } } + internal enum Tag { + /// Email + internal static let email = L10n.tr("Localizable", "Common.Controls.Status.Tag.Email") + /// Emoji + internal static let emoji = L10n.tr("Localizable", "Common.Controls.Status.Tag.Emoji") + /// Hashtag + internal static let hashtag = L10n.tr("Localizable", "Common.Controls.Status.Tag.Hashtag") + /// Link + internal static let link = L10n.tr("Localizable", "Common.Controls.Status.Tag.Link") + /// Mention + internal static let mention = L10n.tr("Localizable", "Common.Controls.Status.Tag.Mention") + /// URL + internal static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url") + } } internal enum Timeline { + internal enum Accessibility { + /// %@ favorites + internal static func countFavorites(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountFavorites", String(describing: p1)) + } + /// %@ reblogs + internal static func countReblogs(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountReblogs", String(describing: p1)) + } + /// %@ replies + internal static func countReplies(_ p1: Any) -> String { + return L10n.tr("Localizable", "Common.Controls.Timeline.Accessibility.CountReplies", String(describing: p1)) + } + } internal enum Header { /// You can’t view Artbot’s profile\n until they unblock you. internal static let blockedWarning = L10n.tr("Localizable", "Common.Controls.Timeline.Header.BlockedWarning") diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift index bc0a8b2d9..3b96299d2 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+StatusTableViewCellDelegate.swift @@ -59,7 +59,6 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider { - func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, contentWarningOverlayViewDidPressed contentWarningOverlayView: ContentWarningOverlayView) { StatusProviderFacade.responseToStatusContentWarningRevealAction(provider: self, cell: cell) } @@ -76,7 +75,11 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { extension StatusTableViewCellDelegate where Self: StatusProvider & MediaPreviewableViewController { func statusTableViewCell(_ cell: StatusTableViewCell, mosaicImageViewContainer: MosaicImageViewContainer, didTapImageView imageView: UIImageView, atIndex index: Int) { - StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) + if UIAccessibility.isVoiceOverRunning, !(self is ThreadViewController) { + StatusProviderFacade.coordinateToStatusThreadScene(for: .primary, provider: self, cell: cell) + } else { + StatusProviderFacade.coordinateToStatusMediaPreviewScene(provider: self, cell: cell, mosaicImageView: mosaicImageViewContainer, didTapImageView: imageView, atIndex: index) + } } } diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 685d11188..b685e9651 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -64,6 +64,12 @@ Please check your internet connection."; "Common.Controls.Firendship.UnblockUser" = "Unblock %@"; "Common.Controls.Firendship.Unmute" = "Unmute"; "Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.Actions.Favorite" = "Favorite"; +"Common.Controls.Status.Actions.Menu" = "Menu"; +"Common.Controls.Status.Actions.Reblog" = "Reblog"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Unreblog"; "Common.Controls.Status.ContentWarning" = "content warning"; "Common.Controls.Status.ContentWarningText" = "cw: %@"; "Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; @@ -75,8 +81,17 @@ Please check your internet connection."; "Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.Tag.Email" = "Email"; +"Common.Controls.Status.Tag.Emoji" = "Emoji"; +"Common.Controls.Status.Tag.Hashtag" = "Hashtag"; +"Common.Controls.Status.Tag.Link" = "Link"; +"Common.Controls.Status.Tag.Mention" = "Mention"; +"Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; +"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; +"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; "Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile until they unblock you."; "Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift index f3037c080..cd019fc9b 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewModel.swift @@ -36,7 +36,7 @@ final class MediaPreviewViewModel: NSObject { switch entity.type { case .image: guard let url = URL(string: entity.url) else { continue } - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: url, thumbnail: thumbnail, altText: entity.descriptionString) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel @@ -60,7 +60,7 @@ final class MediaPreviewViewModel: NSObject { managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser let avatarURL = account.headerImageURLWithFallback(domain: account.domain) - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel @@ -80,7 +80,7 @@ final class MediaPreviewViewModel: NSObject { managedObjectContext.performAndWait { let account = managedObjectContext.object(with: meta.accountObjectID) as! MastodonUser let avatarURL = account.avatarImageURLWithFallback(domain: account.domain) - let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage) + let meta = MediaPreviewImageViewModel.RemoteImagePreviewMeta(url: avatarURL, thumbnail: meta.preloadThumbnailImage, altText: nil) let mediaPreviewImageModel = MediaPreviewImageViewModel(meta: meta) let mediaPreviewImageViewController = MediaPreviewImageViewController() mediaPreviewImageViewController.viewModel = mediaPreviewImageModel diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift index 0e4aa6d89..aa11e494d 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageView.swift @@ -18,6 +18,7 @@ final class MediaPreviewImageView: UIScrollView { imageView.isUserInteractionEnabled = true // accessibility imageView.accessibilityIgnoresInvertColors = true + imageView.isAccessibilityElement = true return imageView }() diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift index 7ac3c2024..e1f2736ff 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewController.swift @@ -76,6 +76,7 @@ extension MediaPreviewImageViewController { guard let image = image else { return } self.previewImageView.imageView.image = image self.previewImageView.setup(image: image, container: self.previewImageView, forceUpdate: true) + self.previewImageView.imageView.accessibilityLabel = self.viewModel.altText } .store(in: &disposeBag) } diff --git a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift index 6be61dfc4..c9afac8c7 100644 --- a/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift +++ b/Mastodon/Scene/MediaPreview/Paging/Image/MediaPreviewImageViewModel.swift @@ -17,10 +17,12 @@ class MediaPreviewImageViewModel { // output let image: CurrentValueSubject + let altText: String? init(meta: RemoteImagePreviewMeta) { self.item = .status(meta) self.image = CurrentValueSubject(meta.thumbnail) + self.altText = meta.altText let url = meta.url ImageDownloader.default.download(URLRequest(url: url), completion: { [weak self] response in @@ -38,6 +40,7 @@ class MediaPreviewImageViewModel { init(meta: LocalImagePreviewMeta) { self.item = .local(meta) self.image = CurrentValueSubject(meta.image) + self.altText = nil } } @@ -64,6 +67,7 @@ extension MediaPreviewImageViewModel { struct RemoteImagePreviewMeta { let url: URL let thumbnail: UIImage? + let altText: String? } struct LocalImagePreviewMeta { diff --git a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift index 486f97e3c..0dccd5930 100644 --- a/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift +++ b/Mastodon/Scene/Share/View/Container/MosaicImageViewContainer.swift @@ -31,6 +31,7 @@ final class MosaicImageViewContainer: UIView { let tapGesture = UITapGestureRecognizer.singleTapGestureRecognizer tapGesture.addTarget(self, action: #selector(MosaicImageViewContainer.photoTapGestureRecognizerHandler(_:))) imageView.addGestureRecognizer(tapGesture) + imageView.isAccessibilityElement = true } } } diff --git a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift index 3dc091aa0..9d9f627dc 100644 --- a/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift +++ b/Mastodon/Scene/Share/View/Content/ContentWarningOverlayView.swift @@ -26,6 +26,7 @@ class ContentWarningOverlayView: UIView { label.text = L10n.Common.Controls.Status.mediaContentWarning label.textAlignment = .center label.numberOfLines = 0 + label.isAccessibilityElement = false return label }() @@ -40,6 +41,7 @@ class ContentWarningOverlayView: UIView { label.text = L10n.Common.Controls.Status.mediaContentWarning label.textColor = Asset.Colors.Label.primary.color label.textAlignment = .center + label.isAccessibilityElement = false return label }() let blurContentWarningLabel: UILabel = { @@ -49,6 +51,7 @@ class ContentWarningOverlayView: UIView { label.textColor = Asset.Colors.Label.secondary.color label.textAlignment = .center label.layer.setupShadow() + label.isAccessibilityElement = false return label }() diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 5ebdd17c5..ffe56b8b8 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -96,6 +96,7 @@ final class StatusView: UIView { label.textColor = Asset.Colors.Label.secondary.color label.font = .systemFont(ofSize: 17) label.text = "·" + label.isAccessibilityElement = false return label }() @@ -104,6 +105,7 @@ final class StatusView: UIView { label.font = .systemFont(ofSize: 15, weight: .regular) label.textColor = Asset.Colors.Label.secondary.color label.text = "@alice" + label.isAccessibilityElement = false return label }() diff --git a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift index a4b0886d6..ff5fa58d6 100644 --- a/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift +++ b/Mastodon/Scene/Share/View/Content/ThreadMetaView.swift @@ -79,6 +79,10 @@ extension ThreadMetaView { favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) updateContainerLayout() + + // TODO: + reblogButton.isAccessibilityElement = false + favoriteButton.isAccessibilityElement = false } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 462577a4b..ca6a9e7eb 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -81,6 +81,7 @@ final class StatusTableViewCell: UITableViewCell, StatusCell { threadMetaView.isHidden = true disposeBag.removeAll() observations.removeAll() + isAccessibilityElement = false // reset behavior } override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -357,3 +358,10 @@ extension StatusTableViewCell: ActionToolbarContainerDelegate { } } + +extension StatusTableViewCell { + override var accessibilityActivationPoint: CGPoint { + get { return .zero } + set { } + } +} diff --git a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift index 2ed31abb4..f10e55941 100644 --- a/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift +++ b/Mastodon/Scene/Share/View/ToolBar/ActionToolBarContainer.swift @@ -105,6 +105,11 @@ extension ActionToolbarContainer { let starImage = UIImage(systemName: "star.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .bold))!.withRenderingMode(.alwaysTemplate) let moreImage = UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .bold))!.withRenderingMode(.alwaysTemplate) + replyButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reply + reblogButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.reblog // needs update to follow state + favoriteButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.favorite // needs update to follow state + moreButton.accessibilityLabel = L10n.Common.Controls.Status.Actions.menu + switch style { case .inline: buttons.forEach { button in @@ -194,6 +199,14 @@ extension ActionToolbarContainer { } +extension ActionToolbarContainer { + + override var accessibilityElements: [Any]? { + get { [replyButton, reblogButton, favoriteButton, moreButton] } + set { } + } +} + #if DEBUG import SwiftUI diff --git a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift index c2ad3d4f6..9563a19cd 100644 --- a/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift +++ b/Mastodon/Scene/Share/ViewModel/MosaicImageViewModel.swift @@ -28,7 +28,8 @@ struct MosaicImageViewModel { let mosaicMeta = MosaicMeta( url: url, size: CGSize(width: width, height: height), - blurhash: element.blurhash + blurhash: element.blurhash, + altText: element.descriptionString ) metas.append(mosaicMeta) } @@ -43,6 +44,7 @@ struct MosaicMeta { let url: URL let size: CGSize let blurhash: String? + let altText: String? let workingQueue = DispatchQueue(label: "org.joinmastodon.Mastodon.MosaicMeta.working-queue", qos: .userInitiated, attributes: .concurrent) From 8d16a1cec418ec89559d388108f007217835d30e Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 11:41:35 +0800 Subject: [PATCH 390/400] chore: update package version --- Mastodon.xcodeproj/project.pbxproj | 4 +--- Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0abd7ad95..0feed3c97 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -756,7 +756,6 @@ DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; - DB0F814C264BBDD900F2A12B /* ActiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ActiveLabel.swift; path = ../ActiveLabel.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; @@ -1646,7 +1645,6 @@ children = ( DBF53F5F25C14E88008AAC7B /* Mastodon.xctestplan */, DBF53F6025C14E9D008AAC7B /* MastodonSDK.xctestplan */, - DB0F814C264BBDD900F2A12B /* ActiveLabel.swift */, DB3D0FED25BAA42200EAA174 /* MastodonSDK */, DB427DD425BAA00100D1B89D /* Mastodon */, DB427DEB25BAA00100D1B89D /* MastodonTests */, @@ -3918,7 +3916,7 @@ repositoryURL = "https://github.com/TwidereProject/ActiveLabel.swift"; requirement = { kind = exactVersion; - version = 5.0.1; + version = 5.0.2; }; }; 2D5981B825E4D7F8000FB903 /* XCRemoteSwiftPackageReference "ThirdPartyMailer" */ = { diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0c80f20e5..38325ae97 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/TwidereProject/ActiveLabel.swift", "state": { "branch": null, - "revision": "40e104063d825d1125ef4b8eeb6460eba8a57483", - "version": "5.0.1" + "revision": "2132a7bf8da2bea74bb49b0b815950ea4d8f6a7e", + "version": "5.0.2" } }, { @@ -69,7 +69,7 @@ "repositoryURL": "https://github.com/onevcat/Kingfisher.git", "state": { "branch": null, - "revision": "15d199e84677303a7004ed2c5ecaa1a90f3863f8", + "revision": "bbc4bc4def7eb05a7ba8e1219f80ee9be327334e", "version": "6.2.1" } }, From ec2be58952211549acfc5b88324b690464413aad Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 14:27:57 +0800 Subject: [PATCH 391/400] feat: add accessibility supports for compose scene --- Localization/app.json | 25 +++++++++- .../Section/CustomEmojiPickerSection.swift | 1 + .../Diffiable/Section/StatusSection.swift | 1 + Mastodon/Generated/Strings.swift | 50 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 17 +++++++ ...tomEmojiPickerItemCollectionViewCell.swift | 4 ++ .../Scene/Compose/ComposeViewController.swift | 26 ++++++++++ .../Compose/View/ComposeToolbarView.swift | 6 +++ .../Scene/MainTab/MainTabBarController.swift | 9 ++-- .../Scene/Profile/ProfileViewController.swift | 6 +++ .../Scene/Share/View/Content/StatusView.swift | 8 ++- 11 files changed, 147 insertions(+), 6 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 98e9accd4..327c72625 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -74,10 +74,17 @@ "settings": "Settings", "delete": "Delete" }, + "tabs": { + "home": "Home", + "search": "Search", + "notification": "Notification", + "profile": "Profile" + }, "status": { "user_reblogged": "%s reblogged", "user_replied_to": "Replied to %s", "show_post": "Show Post", + "show_user_profile": "Show user profile", "content_warning": "content warning", "content_warning_text": "cw: %s", "media_content_warning": "Tap to reveal that may be sensitive", @@ -331,6 +338,17 @@ "unlisted": "Unlisted", "private": "Followers only", "direct": "Only people I mention" + }, + "accessibility": { + "append_attachment": "Append attachment", + "append_poll": "Append poll", + "remove_poll": "Remove poll", + "custom_emoji_picker": "Custom emoji picker", + "enable_content_warning": "Enable content warning", + "disable_content_warning": "Disable content warning", + "post_visibility_menu": "Post visibility menu", + "input_limit_remains_count": "Input limit remains %ld", + "input_limit_exceeds_count": "Input limit exceeds %ld" } }, "profile": { @@ -338,7 +356,12 @@ "dashboard": { "posts": "posts", "following": "following", - "followers": "followers" + "followers": "followers", + "accessibility": { + "count_posts": "%ld posts", + "count_following": "%ld following", + "count_followers": "%ld followers" + } }, "segmented_control": { "posts": "Posts", diff --git a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift index 06b626d0e..20dc5b809 100644 --- a/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift +++ b/Mastodon/Diffiable/Section/CustomEmojiPickerSection.swift @@ -32,6 +32,7 @@ extension CustomEmojiPickerSection { ], completionHandler: nil ) + cell.accessibilityLabel = attribute.emoji.shortcode return cell } } diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index e7ca8182a..d139e061f 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -102,6 +102,7 @@ extension StatusSection { case .root: cell.statusView.activeTextLabel.isAccessibilityElement = false var accessibilityElements: [Any] = [] + accessibilityElements.append(cell.statusView.avatarView) accessibilityElements.append(cell.statusView.nameLabel) accessibilityElements.append(cell.statusView.dateLabel) accessibilityElements.append(contentsOf: cell.statusView.activeTextLabel.createAccessibilityElements()) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 3785a79d9..de69f016e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -200,6 +200,8 @@ internal enum L10n { internal static let mediaContentWarning = L10n.tr("Localizable", "Common.Controls.Status.MediaContentWarning") /// Show Post internal static let showPost = L10n.tr("Localizable", "Common.Controls.Status.ShowPost") + /// Show user profile + internal static let showUserProfile = L10n.tr("Localizable", "Common.Controls.Status.ShowUserProfile") /// %@ reblogged internal static func userReblogged(_ p1: Any) -> String { return L10n.tr("Localizable", "Common.Controls.Status.UserReblogged", String(describing: p1)) @@ -267,6 +269,16 @@ internal enum L10n { internal static let url = L10n.tr("Localizable", "Common.Controls.Status.Tag.Url") } } + internal enum Tabs { + /// Home + internal static let home = L10n.tr("Localizable", "Common.Controls.Tabs.Home") + /// Notification + internal static let notification = L10n.tr("Localizable", "Common.Controls.Tabs.Notification") + /// Profile + internal static let profile = L10n.tr("Localizable", "Common.Controls.Tabs.Profile") + /// Search + internal static let search = L10n.tr("Localizable", "Common.Controls.Tabs.Search") + } internal enum Timeline { internal enum Accessibility { /// %@ favorites @@ -326,6 +338,30 @@ internal enum L10n { internal static func replyingToUser(_ p1: Any) -> String { return L10n.tr("Localizable", "Scene.Compose.ReplyingToUser", String(describing: p1)) } + internal enum Accessibility { + /// Append attachment + internal static let appendAttachment = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendAttachment") + /// Append poll + internal static let appendPoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.AppendPoll") + /// Custom emoji picker + internal static let customEmojiPicker = L10n.tr("Localizable", "Scene.Compose.Accessibility.CustomEmojiPicker") + /// Disable content warning + internal static let disableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.DisableContentWarning") + /// Enable content warning + internal static let enableContentWarning = L10n.tr("Localizable", "Scene.Compose.Accessibility.EnableContentWarning") + /// Input limit exceeds %ld + internal static func inputLimitExceedsCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitExceedsCount", p1) + } + /// Input limit remains %ld + internal static func inputLimitRemainsCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Compose.Accessibility.InputLimitRemainsCount", p1) + } + /// Post visibility menu + internal static let postVisibilityMenu = L10n.tr("Localizable", "Scene.Compose.Accessibility.PostVisibilityMenu") + /// Remove poll + internal static let removePoll = L10n.tr("Localizable", "Scene.Compose.Accessibility.RemovePoll") + } internal enum Attachment { /// This %@ is broken and can't be\nuploaded to Mastodon. internal static func attachmentBroken(_ p1: Any) -> String { @@ -481,6 +517,20 @@ internal enum L10n { internal static let following = L10n.tr("Localizable", "Scene.Profile.Dashboard.Following") /// posts internal static let posts = L10n.tr("Localizable", "Scene.Profile.Dashboard.Posts") + internal enum Accessibility { + /// %ld followers + internal static func countFollowers(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountFollowers", p1) + } + /// %ld following + internal static func countFollowing(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountFollowing", p1) + } + /// %ld posts + internal static func countPosts(_ p1: Int) -> String { + return L10n.tr("Localizable", "Scene.Profile.Dashboard.Accessibility.CountPosts", p1) + } + } } internal enum RelationshipActionAlert { internal enum ConfirmUnblockUsre { diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index b685e9651..0549c4a3b 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -81,6 +81,7 @@ Please check your internet connection."; "Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; "Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; "Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.ShowUserProfile" = "Show user profile"; "Common.Controls.Status.Tag.Email" = "Email"; "Common.Controls.Status.Tag.Emoji" = "Emoji"; "Common.Controls.Status.Tag.Hashtag" = "Hashtag"; @@ -89,6 +90,10 @@ Please check your internet connection."; "Common.Controls.Status.Tag.Url" = "URL"; "Common.Controls.Status.UserReblogged" = "%@ reblogged"; "Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Tabs.Home" = "Home"; +"Common.Controls.Tabs.Notification" = "Notification"; +"Common.Controls.Tabs.Profile" = "Profile"; +"Common.Controls.Tabs.Search" = "Search"; "Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; "Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; "Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; @@ -105,6 +110,15 @@ Your account looks like this to them."; "Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies"; "Common.Countable.Photo.Multiple" = "photos"; "Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment"; +"Scene.Compose.Accessibility.AppendPoll" = "Append poll"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning"; +"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld"; +"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu"; +"Scene.Compose.Accessibility.RemovePoll" = "Remove poll"; "Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be uploaded to Mastodon."; "Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people..."; @@ -159,6 +173,9 @@ tap the link to confirm your account."; "Scene.Notification.Action.Reblog" = "rebloged your post"; "Scene.Notification.Title.Everything" = "Everything"; "Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; +"Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; +"Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; "Scene.Profile.Dashboard.Followers" = "followers"; "Scene.Profile.Dashboard.Following" = "following"; "Scene.Profile.Dashboard.Posts" = "posts"; diff --git a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift index 7acc49aeb..49e6c1fe2 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/CustomEmojiPickerItemCollectionViewCell.swift @@ -47,6 +47,10 @@ extension CustomEmojiPickerItemCollectionViewCell { emojiImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), emojiImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ]) + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityHint = "emoji" } } diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index dedcd4050..3f43588fd 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -260,6 +260,21 @@ extension ComposeViewController { .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeToolbarView.pollButton) .store(in: &disposeBag) + + Publishers.CombineLatest( + viewModel.isPollComposing, + viewModel.isPollToolbarButtonEnabled + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in + guard let self = self else { return } + guard isPollToolbarButtonEnabled else { + self.composeToolbarView.pollButton.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll + return + } + self.composeToolbarView.pollButton.accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll + } + .store(in: &disposeBag) // bind image picker toolbar state viewModel.attachmentServices @@ -271,6 +286,15 @@ extension ComposeViewController { } .store(in: &disposeBag) + // bind content warning button state + viewModel.isContentWarningComposing + .receive(on: DispatchQueue.main) + .sink { [weak self] isContentWarningComposing in + guard let self = self else { return } + self.composeToolbarView.contentWarningButton.accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning + } + .store(in: &disposeBag) + // bind visibility toolbar UI Publishers.CombineLatest( viewModel.selectedStatusVisibility, @@ -294,9 +318,11 @@ extension ComposeViewController { case _ where count < 0: self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold) self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color + self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitExceedsCount(abs(count)) default: self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular) self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color + self.composeToolbarView.characterCountLabel.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(count) } } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 6aabc4572..68f5eed06 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -28,6 +28,7 @@ final class ComposeToolbarView: UIView { let button = HighlightDimmableButton() ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendAttachment return button }() @@ -35,6 +36,7 @@ final class ComposeToolbarView: UIView { let button = HighlightDimmableButton(type: .custom) ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "list.bullet", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll return button }() @@ -45,6 +47,7 @@ final class ComposeToolbarView: UIView { .af.imageScaled(to: CGSize(width: 20, height: 20)) .withRenderingMode(.alwaysTemplate) button.setImage(image, for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.customEmojiPicker return button }() @@ -52,6 +55,7 @@ final class ComposeToolbarView: UIView { let button = HighlightDimmableButton() ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning return button }() @@ -59,6 +63,7 @@ final class ComposeToolbarView: UIView { let button = HighlightDimmableButton() ComposeToolbarView.configureToolbarButtonAppearance(button: button) button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal) + button.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu return button }() @@ -67,6 +72,7 @@ final class ComposeToolbarView: UIView { label.font = .systemFont(ofSize: 15, weight: .regular) label.text = "500" label.textColor = Asset.Colors.Label.secondary.color + label.accessibilityLabel = L10n.Scene.Compose.Accessibility.inputLimitRemainsCount(500) return label }() diff --git a/Mastodon/Scene/MainTab/MainTabBarController.swift b/Mastodon/Scene/MainTab/MainTabBarController.swift index 5fd4c8256..8b1701578 100644 --- a/Mastodon/Scene/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/MainTab/MainTabBarController.swift @@ -25,10 +25,10 @@ class MainTabBarController: UITabBarController { var title: String { switch self { - case .home: return "Home" - case .search: return "Search" - case .notification: return "Notification" - case .me: return "Me" + case .home: return L10n.Common.Controls.Tabs.home + case .search: return L10n.Common.Controls.Tabs.search + case .notification: return L10n.Common.Controls.Tabs.notification + case .me: return L10n.Common.Controls.Tabs.profile } } @@ -99,6 +99,7 @@ extension MainTabBarController { let viewController = tab.viewController(context: context, coordinator: coordinator) viewController.tabBarItem.title = "" // set text to empty string for image only style (SDK failed to layout when set to nil) viewController.tabBarItem.image = tab.image + viewController.tabBarItem.accessibilityLabel = tab.title return viewController } setViewControllers(viewControllers, animated: false) diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index d186d13df..c60c20400 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -479,6 +479,8 @@ extension ProfileViewController { guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Scene.Profile.Dashboard.Accessibility.countPosts(count ?? 0) } .store(in: &disposeBag) viewModel.followingCount @@ -486,6 +488,8 @@ extension ProfileViewController { guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Scene.Profile.Dashboard.Accessibility.countFollowing(count ?? 0) } .store(in: &disposeBag) viewModel.followersCount @@ -493,6 +497,8 @@ extension ProfileViewController { guard let self = self else { return } let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true + self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Scene.Profile.Dashboard.Accessibility.countFollowers(count ?? 0) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index ffe56b8b8..46930f33d 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -75,7 +75,13 @@ final class StatusView: UIView { return label }() - let avatarView = UIView() + let avatarView: UIView = { + let view = UIView() + view.isAccessibilityElement = true + view.accessibilityTraits = .button + view.accessibilityLabel = L10n.Common.Controls.Status.showUserProfile + return view + }() let avatarButton: UIButton = { let button = HighlightDimmableButton(type: .custom) let placeholderImage = UIImage.placeholder(size: avatarImageSize, color: .systemFill) From 78eaf226a4cca690b0ab91e0a2aaafe9face2ed7 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 14:46:40 +0800 Subject: [PATCH 392/400] chore: add ar language code supports --- .../Sources/StringsConvertor/main.swift | 1 + .../StringsConvertor/scripts/build.sh | 4 + Mastodon.xcodeproj/project.pbxproj | 7 + Mastodon/Resources/ar.lproj/InfoPlist.strings | 2 + .../Resources/ar.lproj/Localizable.strings | 302 ++++++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 Mastodon/Resources/ar.lproj/InfoPlist.strings create mode 100644 Mastodon/Resources/ar.lproj/Localizable.strings diff --git a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift index 4ccbb3072..c60266f88 100644 --- a/Localization/StringsConvertor/Sources/StringsConvertor/main.swift +++ b/Localization/StringsConvertor/Sources/StringsConvertor/main.swift @@ -47,6 +47,7 @@ private func map(language: String) -> String? { case "ja_JP": return "ja" case "de_DE": return "de" case "pt_BR": return "pt-BR" + case "ar_SA": return "ar" default: return nil } } diff --git a/Localization/StringsConvertor/scripts/build.sh b/Localization/StringsConvertor/scripts/build.sh index 81e17745c..87087c3a0 100755 --- a/Localization/StringsConvertor/scripts/build.sh +++ b/Localization/StringsConvertor/scripts/build.sh @@ -21,6 +21,10 @@ mkdir -p input/en_US cp ../app.json ./input/en_US cp ../ios-infoPlist.json ./input/en_US +mkdir -p input/ar_SA +cp ../app.json ./input/ar_SA +cp ../ios-infoPlist.json ./input/ar_SA + # curl -o .zip -L ${Crowin_Latest_Build} # unzip -o -q .zip -d input # rm -rf .zip diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0feed3c97..b274f5c7c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -756,6 +756,8 @@ DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveUserInterfaceStyleBarButtonItem.swift; sourceTree = ""; }; DB084B5625CBC56C00F898ED /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; + DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; + DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; @@ -2595,6 +2597,7 @@ knownRegions = ( en, Base, + ar, ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( @@ -3353,6 +3356,7 @@ isa = PBXVariantGroup; children = ( DB2B3ABD25E37E15007045F9 /* en */, + DB0F814E264CFFD300F2A12B /* ar */, ); name = InfoPlist.strings; sourceTree = ""; @@ -3361,6 +3365,7 @@ isa = PBXVariantGroup; children = ( DB3D100E25BAA75E00EAA174 /* en */, + DB0F814D264CFFD300F2A12B /* ar */, ); name = Localizable.strings; sourceTree = ""; @@ -3388,6 +3393,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -3449,6 +3455,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; diff --git a/Mastodon/Resources/ar.lproj/InfoPlist.strings b/Mastodon/Resources/ar.lproj/InfoPlist.strings new file mode 100644 index 000000000..48566ae36 --- /dev/null +++ b/Mastodon/Resources/ar.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"NSCameraUsageDescription" = "Used to take photo for post status"; +"NSPhotoLibraryAddUsageDescription" = "Used to save photo into the Photo Library"; \ No newline at end of file diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings new file mode 100644 index 000000000..0549c4a3b --- /dev/null +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,302 @@ +"Common.Alerts.BlockDomain.BlockEntireDomain" = "Block entire domain"; +"Common.Alerts.BlockDomain.Title" = "Are you really, really sure you want to block the entire %@? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed."; +"Common.Alerts.Common.PleaseTryAgain" = "Please try again."; +"Common.Alerts.Common.PleaseTryAgainLater" = "Please try again later."; +"Common.Alerts.DeletePost.Delete" = "Delete"; +"Common.Alerts.DeletePost.Title" = "Are you sure you want to delete this post?"; +"Common.Alerts.DiscardPostContent.Message" = "Confirm discard composed post content."; +"Common.Alerts.DiscardPostContent.Title" = "Discard Publish"; +"Common.Alerts.PublishPostFailure.Message" = "Failed to publish the post. +Please check your internet connection."; +"Common.Alerts.PublishPostFailure.Title" = "Publish Failure"; +"Common.Alerts.SavePhotoFailure.Message" = "Please enable photo libaray access permission to save photo."; +"Common.Alerts.SavePhotoFailure.Title" = "Save Photo Failure"; +"Common.Alerts.ServerError.Title" = "Server Error"; +"Common.Alerts.SignOut.Confirm" = "Sign Out"; +"Common.Alerts.SignOut.Message" = "Are you sure you want to sign out?"; +"Common.Alerts.SignOut.Title" = "Sign out"; +"Common.Alerts.SignUpFailure.Title" = "Sign Up Failure"; +"Common.Alerts.VoteFailure.PollExpired" = "The poll has expired"; +"Common.Alerts.VoteFailure.Title" = "Vote Failure"; +"Common.Controls.Actions.Add" = "Add"; +"Common.Controls.Actions.Back" = "Back"; +"Common.Controls.Actions.BlockDomain" = "Block %@"; +"Common.Controls.Actions.Cancel" = "Cancel"; +"Common.Controls.Actions.Confirm" = "Confirm"; +"Common.Controls.Actions.Continue" = "Continue"; +"Common.Controls.Actions.Delete" = "Delete"; +"Common.Controls.Actions.Discard" = "Discard"; +"Common.Controls.Actions.Done" = "Done"; +"Common.Controls.Actions.Edit" = "Edit"; +"Common.Controls.Actions.FindPeople" = "Find people to follow"; +"Common.Controls.Actions.ManuallySearch" = "Manually search instead"; +"Common.Controls.Actions.Ok" = "OK"; +"Common.Controls.Actions.OpenInSafari" = "Open in Safari"; +"Common.Controls.Actions.Preview" = "Preview"; +"Common.Controls.Actions.Remove" = "Remove"; +"Common.Controls.Actions.ReportUser" = "Report %@"; +"Common.Controls.Actions.Save" = "Save"; +"Common.Controls.Actions.SavePhoto" = "Save photo"; +"Common.Controls.Actions.SeeMore" = "See More"; +"Common.Controls.Actions.Settings" = "Settings"; +"Common.Controls.Actions.Share" = "Share"; +"Common.Controls.Actions.SharePost" = "Share post"; +"Common.Controls.Actions.ShareUser" = "Share %@"; +"Common.Controls.Actions.SignIn" = "Sign In"; +"Common.Controls.Actions.SignUp" = "Sign Up"; +"Common.Controls.Actions.Skip" = "Skip"; +"Common.Controls.Actions.TakePhoto" = "Take photo"; +"Common.Controls.Actions.TryAgain" = "Try Again"; +"Common.Controls.Actions.UnblockDomain" = "Unblock %@"; +"Common.Controls.Firendship.Block" = "Block"; +"Common.Controls.Firendship.BlockDomain" = "Block %@"; +"Common.Controls.Firendship.BlockUser" = "Block %@"; +"Common.Controls.Firendship.Blocked" = "Blocked"; +"Common.Controls.Firendship.EditInfo" = "Edit info"; +"Common.Controls.Firendship.Follow" = "Follow"; +"Common.Controls.Firendship.Following" = "Following"; +"Common.Controls.Firendship.Mute" = "Mute"; +"Common.Controls.Firendship.MuteUser" = "Mute %@"; +"Common.Controls.Firendship.Muted" = "Muted"; +"Common.Controls.Firendship.Pending" = "Pending"; +"Common.Controls.Firendship.Request" = "Request"; +"Common.Controls.Firendship.Unblock" = "Unblock"; +"Common.Controls.Firendship.UnblockUser" = "Unblock %@"; +"Common.Controls.Firendship.Unmute" = "Unmute"; +"Common.Controls.Firendship.UnmuteUser" = "Unmute %@"; +"Common.Controls.Status.Actions.Favorite" = "Favorite"; +"Common.Controls.Status.Actions.Menu" = "Menu"; +"Common.Controls.Status.Actions.Reblog" = "Reblog"; +"Common.Controls.Status.Actions.Reply" = "Reply"; +"Common.Controls.Status.Actions.Unfavorite" = "Unfavorite"; +"Common.Controls.Status.Actions.Unreblog" = "Unreblog"; +"Common.Controls.Status.ContentWarning" = "content warning"; +"Common.Controls.Status.ContentWarningText" = "cw: %@"; +"Common.Controls.Status.MediaContentWarning" = "Tap to reveal that may be sensitive"; +"Common.Controls.Status.Poll.Closed" = "Closed"; +"Common.Controls.Status.Poll.TimeLeft" = "%@ left"; +"Common.Controls.Status.Poll.Vote" = "Vote"; +"Common.Controls.Status.Poll.VoteCount.Multiple" = "%d votes"; +"Common.Controls.Status.Poll.VoteCount.Single" = "%d vote"; +"Common.Controls.Status.Poll.VoterCount.Multiple" = "%d voters"; +"Common.Controls.Status.Poll.VoterCount.Single" = "%d voter"; +"Common.Controls.Status.ShowPost" = "Show Post"; +"Common.Controls.Status.ShowUserProfile" = "Show user profile"; +"Common.Controls.Status.Tag.Email" = "Email"; +"Common.Controls.Status.Tag.Emoji" = "Emoji"; +"Common.Controls.Status.Tag.Hashtag" = "Hashtag"; +"Common.Controls.Status.Tag.Link" = "Link"; +"Common.Controls.Status.Tag.Mention" = "Mention"; +"Common.Controls.Status.Tag.Url" = "URL"; +"Common.Controls.Status.UserReblogged" = "%@ reblogged"; +"Common.Controls.Status.UserRepliedTo" = "Replied to %@"; +"Common.Controls.Tabs.Home" = "Home"; +"Common.Controls.Tabs.Notification" = "Notification"; +"Common.Controls.Tabs.Profile" = "Profile"; +"Common.Controls.Tabs.Search" = "Search"; +"Common.Controls.Timeline.Accessibility.CountFavorites" = "%@ favorites"; +"Common.Controls.Timeline.Accessibility.CountReblogs" = "%@ reblogs"; +"Common.Controls.Timeline.Accessibility.CountReplies" = "%@ replies"; +"Common.Controls.Timeline.Header.BlockedWarning" = "You can’t view Artbot’s profile + until they unblock you."; +"Common.Controls.Timeline.Header.BlockingWarning" = "You can’t view Artbot’s profile + until you unblock them. +Your account looks like this to them."; +"Common.Controls.Timeline.Header.NoStatusFound" = "No Status Found"; +"Common.Controls.Timeline.Header.SuspendedWarning" = "This account has been suspended."; +"Common.Controls.Timeline.Header.UserSuspendedWarning" = "%@'s account has been suspended."; +"Common.Controls.Timeline.Loader.LoadMissingPosts" = "Load missing posts"; +"Common.Controls.Timeline.Loader.LoadingMissingPosts" = "Loading missing posts..."; +"Common.Controls.Timeline.Loader.ShowMoreReplies" = "Show more replies"; +"Common.Countable.Photo.Multiple" = "photos"; +"Common.Countable.Photo.Single" = "photo"; +"Scene.Compose.Accessibility.AppendAttachment" = "Append attachment"; +"Scene.Compose.Accessibility.AppendPoll" = "Append poll"; +"Scene.Compose.Accessibility.CustomEmojiPicker" = "Custom emoji picker"; +"Scene.Compose.Accessibility.DisableContentWarning" = "Disable content warning"; +"Scene.Compose.Accessibility.EnableContentWarning" = "Enable content warning"; +"Scene.Compose.Accessibility.InputLimitExceedsCount" = "Input limit exceeds %ld"; +"Scene.Compose.Accessibility.InputLimitRemainsCount" = "Input limit remains %ld"; +"Scene.Compose.Accessibility.PostVisibilityMenu" = "Post visibility menu"; +"Scene.Compose.Accessibility.RemovePoll" = "Remove poll"; +"Scene.Compose.Attachment.AttachmentBroken" = "This %@ is broken and can't be +uploaded to Mastodon."; +"Scene.Compose.Attachment.DescriptionPhoto" = "Describe photo for low vision people..."; +"Scene.Compose.Attachment.DescriptionVideo" = "Describe what’s happening for low vision people..."; +"Scene.Compose.Attachment.Photo" = "photo"; +"Scene.Compose.Attachment.Video" = "video"; +"Scene.Compose.ComposeAction" = "Publish"; +"Scene.Compose.ContentInputPlaceholder" = "Type or paste what's on your mind"; +"Scene.Compose.ContentWarning.Placeholder" = "Write an accurate warning here..."; +"Scene.Compose.MediaSelection.Browse" = "Browse"; +"Scene.Compose.MediaSelection.Camera" = "Take Photo"; +"Scene.Compose.MediaSelection.PhotoLibrary" = "Photo Library"; +"Scene.Compose.Poll.DurationTime" = "Duration: %@"; +"Scene.Compose.Poll.OneDay" = "1 Day"; +"Scene.Compose.Poll.OneHour" = "1 Hour"; +"Scene.Compose.Poll.OptionNumber" = "Option %ld"; +"Scene.Compose.Poll.SevenDays" = "7 Days"; +"Scene.Compose.Poll.SixHours" = "6 Hours"; +"Scene.Compose.Poll.ThirtyMinutes" = "30 minutes"; +"Scene.Compose.Poll.ThreeDays" = "3 Days"; +"Scene.Compose.ReplyingToUser" = "replying to %@"; +"Scene.Compose.Title.NewPost" = "New Post"; +"Scene.Compose.Title.NewReply" = "New Reply"; +"Scene.Compose.Visibility.Direct" = "Only people I mention"; +"Scene.Compose.Visibility.Private" = "Followers only"; +"Scene.Compose.Visibility.Public" = "Public"; +"Scene.Compose.Visibility.Unlisted" = "Unlisted"; +"Scene.ConfirmEmail.Button.DontReceiveEmail" = "I never got an email"; +"Scene.ConfirmEmail.Button.OpenEmailApp" = "Open Email App"; +"Scene.ConfirmEmail.DontReceiveEmail.Description" = "Check if your email address is correct as well as your junk folder if you haven’t."; +"Scene.ConfirmEmail.DontReceiveEmail.ResendEmail" = "Resend Email"; +"Scene.ConfirmEmail.DontReceiveEmail.Title" = "Check your email"; +"Scene.ConfirmEmail.OpenEmailApp.Description" = "We just sent you an email. Check your junk folder if you haven’t."; +"Scene.ConfirmEmail.OpenEmailApp.Mail" = "Mail"; +"Scene.ConfirmEmail.OpenEmailApp.OpenEmailClient" = "Open Email Client"; +"Scene.ConfirmEmail.OpenEmailApp.Title" = "Check your inbox."; +"Scene.ConfirmEmail.Subtitle" = "We just sent an email to %@, +tap the link to confirm your account."; +"Scene.ConfirmEmail.Title" = "One last thing."; +"Scene.Favorite.Title" = "Your Favorites"; +"Scene.Hashtag.Prompt" = "%@ people talking"; +"Scene.HomeTimeline.NavigationBarState.NewPosts" = "See new posts"; +"Scene.HomeTimeline.NavigationBarState.Offline" = "Offline"; +"Scene.HomeTimeline.NavigationBarState.Published" = "Published!"; +"Scene.HomeTimeline.NavigationBarState.Publishing" = "Publishing post..."; +"Scene.HomeTimeline.Title" = "Home"; +"Scene.Notification.Action.Favourite" = "favorited your post"; +"Scene.Notification.Action.Follow" = "followed you"; +"Scene.Notification.Action.FollowRequest" = "request to follow you"; +"Scene.Notification.Action.Mention" = "mentioned you"; +"Scene.Notification.Action.Poll" = "Your poll has ended"; +"Scene.Notification.Action.Reblog" = "rebloged your post"; +"Scene.Notification.Title.Everything" = "Everything"; +"Scene.Notification.Title.Mentions" = "Mentions"; +"Scene.Profile.Dashboard.Accessibility.CountFollowers" = "%ld followers"; +"Scene.Profile.Dashboard.Accessibility.CountFollowing" = "%ld following"; +"Scene.Profile.Dashboard.Accessibility.CountPosts" = "%ld posts"; +"Scene.Profile.Dashboard.Followers" = "followers"; +"Scene.Profile.Dashboard.Following" = "following"; +"Scene.Profile.Dashboard.Posts" = "posts"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Message" = "Confirm unblock %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnblockUsre.Title" = "Unblock Account"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Message" = "Confirm unmute %@"; +"Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.Title" = "Unmute Account"; +"Scene.Profile.SegmentedControl.Media" = "Media"; +"Scene.Profile.SegmentedControl.Posts" = "Posts"; +"Scene.Profile.SegmentedControl.Replies" = "Replies"; +"Scene.Profile.Subtitle" = "%@ posts"; +"Scene.PublicTimeline.Title" = "Public"; +"Scene.Register.Error.Item.Agreement" = "Agreement"; +"Scene.Register.Error.Item.Email" = "Email"; +"Scene.Register.Error.Item.Locale" = "Locale"; +"Scene.Register.Error.Item.Password" = "Password"; +"Scene.Register.Error.Item.Reason" = "Reason"; +"Scene.Register.Error.Item.Username" = "Username"; +"Scene.Register.Error.Reason.Accepted" = "%@ must be accepted"; +"Scene.Register.Error.Reason.Blank" = "%@ is required"; +"Scene.Register.Error.Reason.Blocked" = "%@ contains a disallowed e-mail provider"; +"Scene.Register.Error.Reason.Inclusion" = "%@ is not a supported value"; +"Scene.Register.Error.Reason.Invalid" = "%@ is invalid"; +"Scene.Register.Error.Reason.Reserved" = "%@ is a reserved keyword"; +"Scene.Register.Error.Reason.Taken" = "%@ is already in use"; +"Scene.Register.Error.Reason.TooLong" = "%@ is too long"; +"Scene.Register.Error.Reason.TooShort" = "%@ is too short"; +"Scene.Register.Error.Reason.Unreachable" = "%@ does not seem to exist"; +"Scene.Register.Error.Special.EmailInvalid" = "This is not a valid e-mail address"; +"Scene.Register.Error.Special.PasswordTooShort" = "Password is too short (must be at least 8 characters)"; +"Scene.Register.Error.Special.UsernameInvalid" = "Username must only contain alphanumeric characters and underscores"; +"Scene.Register.Error.Special.UsernameTooLong" = "Username is too long (can't be longer than 30 characters)"; +"Scene.Register.Input.Avatar.Delete" = "Delete"; +"Scene.Register.Input.DisplayName.Placeholder" = "display name"; +"Scene.Register.Input.Email.Placeholder" = "email"; +"Scene.Register.Input.Invite.RegistrationUserInviteRequest" = "Why do you want to join?"; +"Scene.Register.Input.Password.Hint" = "Your password needs at least eight characters"; +"Scene.Register.Input.Password.Placeholder" = "password"; +"Scene.Register.Input.Username.DuplicatePrompt" = "This username is taken."; +"Scene.Register.Input.Username.Placeholder" = "username"; +"Scene.Register.Title" = "Tell us about you."; +"Scene.Report.Content1" = "Are there any other posts you’d like to add to the report?"; +"Scene.Report.Content2" = "Is there anything the moderators should know about this report?"; +"Scene.Report.Send" = "Send Report"; +"Scene.Report.SkipToSend" = "Send without comment"; +"Scene.Report.Step1" = "Step 1 of 2"; +"Scene.Report.Step2" = "Step 2 of 2"; +"Scene.Report.TextPlaceholder" = "Type or paste additional comments"; +"Scene.Report.Title" = "Report %@"; +"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Follow" = "Follow"; +"Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; +"Scene.Search.Recommend.ButtonText" = "See All"; +"Scene.Search.Recommend.HashTag.Description" = "Hashtags that are getting quite a bit of attention among people you follow"; +"Scene.Search.Recommend.HashTag.PeopleTalking" = "%@ people are talking"; +"Scene.Search.Recommend.HashTag.Title" = "Trending in your timeline"; +"Scene.Search.Searchbar.Cancel" = "Cancel"; +"Scene.Search.Searchbar.Placeholder" = "Search hashtags and users"; +"Scene.Search.Searching.Clear" = "clear"; +"Scene.Search.Searching.RecentSearch" = "Recent searches"; +"Scene.Search.Searching.Segment.All" = "All"; +"Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; +"Scene.Search.Searching.Segment.People" = "People"; +"Scene.ServerPicker.Button.Category.Academia" = "academia"; +"Scene.ServerPicker.Button.Category.Activism" = "activism"; +"Scene.ServerPicker.Button.Category.All" = "All"; +"Scene.ServerPicker.Button.Category.AllAccessiblityDescription" = "Category: All"; +"Scene.ServerPicker.Button.Category.Art" = "art"; +"Scene.ServerPicker.Button.Category.Food" = "food"; +"Scene.ServerPicker.Button.Category.Furry" = "furry"; +"Scene.ServerPicker.Button.Category.Games" = "games"; +"Scene.ServerPicker.Button.Category.General" = "general"; +"Scene.ServerPicker.Button.Category.Journalism" = "journalism"; +"Scene.ServerPicker.Button.Category.Lgbt" = "lgbt"; +"Scene.ServerPicker.Button.Category.Music" = "music"; +"Scene.ServerPicker.Button.Category.Regional" = "regional"; +"Scene.ServerPicker.Button.Category.Tech" = "tech"; +"Scene.ServerPicker.Button.SeeLess" = "See Less"; +"Scene.ServerPicker.Button.SeeMore" = "See More"; +"Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; +"Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; +"Scene.ServerPicker.Label.Category" = "CATEGORY"; +"Scene.ServerPicker.Label.Language" = "LANGUAGE"; +"Scene.ServerPicker.Label.Users" = "USERS"; +"Scene.ServerPicker.Title" = "Pick a Server, +any server."; +"Scene.ServerRules.Button.Confirm" = "I Agree"; +"Scene.ServerRules.PrivacyPolicy" = "privacy policy"; +"Scene.ServerRules.Prompt" = "By continuing, you're subject to the terms of service and privacy policy for %@."; +"Scene.ServerRules.Subtitle" = "These rules are set by the admins of %@."; +"Scene.ServerRules.TermsOfService" = "terms of service"; +"Scene.ServerRules.Title" = "Some ground rules."; +"Scene.Settings.Section.Appearance.Automatic" = "Automatic"; +"Scene.Settings.Section.Appearance.Dark" = "Always Dark"; +"Scene.Settings.Section.Appearance.Light" = "Always Light"; +"Scene.Settings.Section.Appearance.Title" = "Appearance"; +"Scene.Settings.Section.Boringzone.Privacy" = "Privacy Policy"; +"Scene.Settings.Section.Boringzone.Terms" = "Terms of Service"; +"Scene.Settings.Section.Boringzone.Title" = "The Boring zone"; +"Scene.Settings.Section.Notifications.Boosts" = "Reblogs my post"; +"Scene.Settings.Section.Notifications.Favorites" = "Favorites my post"; +"Scene.Settings.Section.Notifications.Follows" = "Follows me"; +"Scene.Settings.Section.Notifications.Mentions" = "Mentions me"; +"Scene.Settings.Section.Notifications.Title" = "Notifications"; +"Scene.Settings.Section.Notifications.Trigger.Anyone" = "anyone"; +"Scene.Settings.Section.Notifications.Trigger.Follow" = "anyone I follow"; +"Scene.Settings.Section.Notifications.Trigger.Follower" = "a follower"; +"Scene.Settings.Section.Notifications.Trigger.Noone" = "no one"; +"Scene.Settings.Section.Notifications.Trigger.Title" = "Notify me when"; +"Scene.Settings.Section.Spicyzone.Clear" = "Clear Media Cache"; +"Scene.Settings.Section.Spicyzone.Signout" = "Sign Out"; +"Scene.Settings.Section.Spicyzone.Title" = "The spicy zone"; +"Scene.Settings.Title" = "Settings"; +"Scene.SuggestionAccount.FollowExplain" = "When you follow someone, you’ll see their posts in your home feed."; +"Scene.SuggestionAccount.Title" = "Find People to Follow"; +"Scene.Thread.BackTitle" = "Post"; +"Scene.Thread.Favorite.Multiple" = "%@ favorites"; +"Scene.Thread.Favorite.Single" = "%@ favorite"; +"Scene.Thread.Reblog.Multiple" = "%@ reblogs"; +"Scene.Thread.Reblog.Single" = "%@ reblog"; +"Scene.Thread.Title" = "Post from %@"; +"Scene.Welcome.Slogan" = "Social networking +back in your hands."; \ No newline at end of file From eb86247419aeb3f110d274ba8ed6dea3f9fb024c Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 15:57:29 +0800 Subject: [PATCH 393/400] fix: avatar placeholder image corner set with scale issue --- Mastodon/Protocol/AvatarConfigurableView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Protocol/AvatarConfigurableView.swift b/Mastodon/Protocol/AvatarConfigurableView.swift index e33c01278..40ef91153 100644 --- a/Mastodon/Protocol/AvatarConfigurableView.swift +++ b/Mastodon/Protocol/AvatarConfigurableView.swift @@ -26,7 +26,7 @@ extension AvatarConfigurableView { if Self.configurableAvatarImageCornerRadius < Self.configurableAvatarImageSize.width * 0.5 { return placeholderImage .af.imageAspectScaled(toFill: Self.configurableAvatarImageSize) - .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: true) + .af.imageRounded(withCornerRadius: Self.configurableAvatarImageCornerRadius, divideRadiusByImageScale: false) } else { return placeholderImage.af.imageRoundedIntoCircle() } From 93c9289b1f8e25e998658e833dd3bf8db8ba9b9e Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 16:02:00 +0800 Subject: [PATCH 394/400] fix: player content overlay layout issue. resolve #132 --- .../View/Container/PlayerContainerView.swift | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift index d018dfbe5..32ee48df9 100644 --- a/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift +++ b/Mastodon/Scene/Share/View/Container/PlayerContainerView.swift @@ -75,16 +75,6 @@ extension PlayerContainerView { mediaTypeIndicotorView.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) - contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false - addSubview(contentWarningOverlayView) - NSLayoutConstraint.activate([ - contentWarningOverlayView.topAnchor.constraint(equalTo: topAnchor), - contentWarningOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor), - contentWarningOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor), - contentWarningOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - contentWarningOverlayView.delegate = self - mediaTypeIndicotorViewInContentWarningOverlay.translatesAutoresizingMaskIntoConstraints = false contentWarningOverlayView.addSubview(mediaTypeIndicotorViewInContentWarningOverlay) NSLayoutConstraint.activate([ @@ -93,6 +83,8 @@ extension PlayerContainerView { mediaTypeIndicotorViewInContentWarningOverlay.heightAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.height).priority(.required - 1), mediaTypeIndicotorViewInContentWarningOverlay.widthAnchor.constraint(equalToConstant: MediaTypeIndicotorView.indicatorViewSize.width).priority(.required - 1), ]) + + contentWarningOverlayView.delegate = self } } @@ -147,6 +139,16 @@ extension PlayerContainerView { containerHeightLayoutConstraint.constant = floor(rect.height) containerHeightLayoutConstraint.isActive = true + contentWarningOverlayView.removeFromSuperview() + contentWarningOverlayView.translatesAutoresizingMaskIntoConstraints = false + addSubview(contentWarningOverlayView) + NSLayoutConstraint.activate([ + contentWarningOverlayView.topAnchor.constraint(equalTo: touchBlockingView.topAnchor), + contentWarningOverlayView.leadingAnchor.constraint(equalTo: touchBlockingView.leadingAnchor), + contentWarningOverlayView.trailingAnchor.constraint(equalTo: touchBlockingView.trailingAnchor), + contentWarningOverlayView.bottomAnchor.constraint(equalTo: touchBlockingView.bottomAnchor) + ]) + bringSubviewToFront(mediaTypeIndicotorView) return playerViewController From d8ce63df80f001522ed1798870822739ff84c0af Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 16:06:28 +0800 Subject: [PATCH 395/400] chore: update i18n. resolve #94 --- Localization/app.json | 2 +- Mastodon/Generated/Strings.swift | 2 +- Mastodon/Resources/ar.lproj/Localizable.strings | 2 +- Mastodon/Resources/en.lproj/Localizable.strings | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Localization/app.json b/Localization/app.json index 327c72625..ad35fed5f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -393,7 +393,7 @@ }, "accounts": { "title": "Accounts you might like", - "description": "Except for Sam, you will not like his account.", + "description": "You may like to follow these accounts", "follow": "Follow" } }, diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index de69f016e..d179be707 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -690,7 +690,7 @@ internal enum L10n { /// See All internal static let buttonText = L10n.tr("Localizable", "Scene.Search.Recommend.ButtonText") internal enum Accounts { - /// Except for Sam, you will not like his account. + /// You may like to follow these accounts internal static let description = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Description") /// Follow internal static let follow = L10n.tr("Localizable", "Scene.Search.Recommend.Accounts.Follow") diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 0549c4a3b..7b9e0e43c 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -225,7 +225,7 @@ tap the link to confirm your account."; "Scene.Report.Step2" = "Step 2 of 2"; "Scene.Report.TextPlaceholder" = "Type or paste additional comments"; "Scene.Report.Title" = "Report %@"; -"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; "Scene.Search.Recommend.ButtonText" = "See All"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 0549c4a3b..7b9e0e43c 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -225,7 +225,7 @@ tap the link to confirm your account."; "Scene.Report.Step2" = "Step 2 of 2"; "Scene.Report.TextPlaceholder" = "Type or paste additional comments"; "Scene.Report.Title" = "Report %@"; -"Scene.Search.Recommend.Accounts.Description" = "Except for Sam, you will not like his account."; +"Scene.Search.Recommend.Accounts.Description" = "You may like to follow these accounts"; "Scene.Search.Recommend.Accounts.Follow" = "Follow"; "Scene.Search.Recommend.Accounts.Title" = "Accounts you might like"; "Scene.Search.Recommend.ButtonText" = "See All"; From 5e0eb598d1d2aec3427c347d63fc0597ca517179 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 16:21:06 +0800 Subject: [PATCH 396/400] fix: separator line display issue. resolve #123 --- .../NotificationViewController.swift | 6 +- .../NotificationStatusTableViewCell.swift | 54 ++++++++++++++++++ .../NotificationTableViewCell.swift | 55 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index 73e385ef8..aea9d3318 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -28,14 +28,14 @@ final class NotificationViewController: UIViewController, NeedsDependency { let tableView: UITableView = { let tableView = ControlContainableTableView() - tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .singleLine tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self)) tableView.register(NotificationStatusTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationStatusTableViewCell.self)) tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) - tableView.tableFooterView = UIView() + tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() tableView.backgroundColor = .clear return tableView }() diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift index 5ae5d0136..a950ede46 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationStatusTableViewCell.swift @@ -76,6 +76,14 @@ final class NotificationStatusTableViewCell: UITableViewCell, StatusCell { let statusView = StatusView() + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() @@ -197,6 +205,18 @@ extension NotificationStatusTableViewCell { containerStackView.addArrangedSubview(statusStackView) + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() + // remove item don't display statusView.actionToolbarContainer.removeFromStackView() // it affect stackView's height,need remove @@ -206,6 +226,8 @@ extension NotificationStatusTableViewCell { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + + resetSeparatorLineLayout() statusBorder.layer.borderColor = Asset.Colors.Border.notification.color.cgColor actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor } @@ -258,5 +280,37 @@ extension NotificationStatusTableViewCell: StatusViewDelegate { // do nothing } +} + +extension NotificationStatusTableViewCell { + + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } } diff --git a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift index 49d32530b..067283935 100644 --- a/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift +++ b/Mastodon/Scene/Notification/TableViewCell/NotificationTableViewCell.swift @@ -98,6 +98,14 @@ final class NotificationTableViewCell: UITableViewCell { let buttonStackView = UIStackView() + let separatorLine = UIView.separatorLine + + var separatorLineToEdgeLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToEdgeTrailingLayoutConstraint: NSLayoutConstraint! + + var separatorLineToMarginLeadingLayoutConstraint: NSLayoutConstraint! + var separatorLineToMarginTrailingLayoutConstraint: NSLayoutConstraint! + override func prepareForReuse() { super.prepareForReuse() avatatImageView.af.cancelImageRequest() @@ -187,10 +195,57 @@ extension NotificationTableViewCell { buttonStackView.addArrangedSubview(acceptButton) buttonStackView.addArrangedSubview(rejectButton) containerStackView.addArrangedSubview(buttonStackView) + + separatorLine.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(separatorLine) + separatorLineToEdgeLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + separatorLineToEdgeTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) + separatorLineToMarginLeadingLayoutConstraint = separatorLine.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor) + separatorLineToMarginTrailingLayoutConstraint = separatorLine.trailingAnchor.constraint(equalTo: contentView.readableContentGuide.trailingAnchor) + NSLayoutConstraint.activate([ + separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView)), + ]) + resetSeparatorLineLayout() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + actionImageBackground.layer.borderColor = Asset.Colors.Background.systemBackground.color.cgColor + resetSeparatorLineLayout() } } + +extension NotificationTableViewCell { + + private func resetSeparatorLineLayout() { + separatorLineToEdgeLeadingLayoutConstraint.isActive = false + separatorLineToEdgeTrailingLayoutConstraint.isActive = false + separatorLineToMarginLeadingLayoutConstraint.isActive = false + separatorLineToMarginTrailingLayoutConstraint.isActive = false + + if traitCollection.userInterfaceIdiom == .phone { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + if traitCollection.horizontalSizeClass == .compact { + // to edge + NSLayoutConstraint.activate([ + separatorLineToEdgeLeadingLayoutConstraint, + separatorLineToEdgeTrailingLayoutConstraint, + ]) + } else { + // to margin + NSLayoutConstraint.activate([ + separatorLineToMarginLeadingLayoutConstraint, + separatorLineToMarginTrailingLayoutConstraint, + ]) + } + } + } + +} From d31061890884526350ad4ca33deb5d6c460c5ae2 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 16:28:02 +0800 Subject: [PATCH 397/400] fix: content warning overlay clip author view issue --- Mastodon/Scene/Share/View/Content/StatusView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mastodon/Scene/Share/View/Content/StatusView.swift b/Mastodon/Scene/Share/View/Content/StatusView.swift index 46930f33d..1aea1bcc3 100644 --- a/Mastodon/Scene/Share/View/Content/StatusView.swift +++ b/Mastodon/Scene/Share/View/Content/StatusView.swift @@ -360,7 +360,7 @@ extension StatusView { // only layout to top and left & right then draw image to fit size ]) // avoid overlay clip author view - containerStackView.bringSubviewToFront(authorContainerStackView) + containerStackView.bringSubviewToFront(authorContainerView) // status statusContainerStackView.addArrangedSubview(activeTextLabel) From 46baa59d3705c0303e7cc06df9b09bb3ac9fd333 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 17:50:37 +0800 Subject: [PATCH 398/400] feat: add picker server loader. Set chevron image for expand button --- Localization/app.json | 3 +- Mastodon.xcodeproj/project.pbxproj | 6 +- Mastodon/Diffiable/Item/PickServerItem.swift | 25 ++++++ .../Diffiable/Section/PickServerSection.swift | 24 ++++++ Mastodon/Generated/Strings.swift | 2 + .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../MastodonPickServerViewController.swift | 1 + .../MastodonPickServerViewModel.swift | 45 +++++++--- .../TableViewCell/PickServerCell.swift | 11 ++- .../PickServerLoaderTableViewCell.swift | 86 +++++++++++++++++++ .../TimelineLoaderTableViewCell.swift | 7 +- .../Webview/WebViewController.swift | 0 .../{ => Share}/Webview/WebViewModel.swift | 0 14 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift rename Mastodon/Scene/{ => Share}/Webview/WebViewController.swift (100%) rename Mastodon/Scene/{ => Share}/Webview/WebViewModel.swift (100%) diff --git a/Localization/app.json b/Localization/app.json index ad35fed5f..21304d564 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -199,7 +199,8 @@ }, "empty_state": { "finding_servers": "Finding available servers...", - "bad_network": "Something went wrong while loading data. Check your internet connection." + "bad_network": "Something went wrong while loading data. Check your internet connection.", + "no_results": "No results" } }, "register": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b274f5c7c..0eb170fc5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; @@ -758,6 +759,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; @@ -1136,6 +1138,7 @@ 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, 0FB3D33725E6401400AAD544 /* PickServerCell.swift */, + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1390,6 +1393,7 @@ 2D7631A425C1532200929FB9 /* Share */ = { isa = PBXGroup; children = ( + 5D03938E2612D200007FE196 /* Webview */, DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, @@ -2047,7 +2051,6 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, DB6180E426391A500018D199 /* Transition */, DB8AF54E25C13703002E6C99 /* MainTab */, @@ -2983,6 +2986,7 @@ DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PickServerItem.swift b/Mastodon/Diffiable/Item/PickServerItem.swift index 13acefeae..1ae38ba1c 100644 --- a/Mastodon/Diffiable/Item/PickServerItem.swift +++ b/Mastodon/Diffiable/Item/PickServerItem.swift @@ -14,6 +14,7 @@ enum PickServerItem { case categoryPicker(items: [CategoryPickerItem]) case search case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) + case loader(attribute: LoaderItemAttribute) } extension PickServerItem { @@ -34,6 +35,26 @@ extension PickServerItem { hasher.combine(isExpand) } } + + final class LoaderItemAttribute: Equatable, Hashable { + let id = UUID() + + var isLast: Bool + var isNoResult: Bool + + init(isLast: Bool, isEmptyResult: Bool) { + self.isLast = isLast + self.isNoResult = isEmptyResult + } + + static func == (lhs: PickServerItem.LoaderItemAttribute, rhs: PickServerItem.LoaderItemAttribute) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } } extension PickServerItem: Equatable { @@ -47,6 +68,8 @@ extension PickServerItem: Equatable { return true case (.server(let serverLeft, _), .server(let serverRight, _)): return serverLeft.domain == serverRight.domain + case (.loader(let attributeLeft), loader(let attributeRight)): + return attributeLeft == attributeRight default: return false } @@ -64,6 +87,8 @@ extension PickServerItem: Hashable { hasher.combine(String(describing: PickServerItem.search.self)) case .server(let server, _): hasher.combine(server.domain) + case .loader(let attribute): + hasher.combine(attribute) } } } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index f5b1ee500..aaafb8ce7 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -57,6 +57,10 @@ extension PickServerSection { PickServerSection.configure(cell: cell, server: server, attribute: attribute) cell.delegate = pickServerCellDelegate return cell + case .loader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell + PickServerSection.configure(cell: cell, attribute: attribute) + return cell } } } @@ -137,3 +141,23 @@ extension PickServerSection { } } + +extension PickServerSection { + + static func configure(cell: PickServerLoaderTableViewCell, attribute: PickServerItem.LoaderItemAttribute) { + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + + attribute.isNoResult ? cell.stopAnimating() : cell.startAnimating() + cell.emptyStatusLabel.isHidden = !attribute.isNoResult + } + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d179be707..8f6c13f9e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -773,6 +773,8 @@ internal enum L10n { internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") /// Finding available servers... internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") + /// No results + internal static let noResults = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.NoResults") } internal enum Input { /// Find a server or join your own... diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 7b9e0e43c..c9ed556c3 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -257,6 +257,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 7b9e0e43c..c9ed556c3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -257,6 +257,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 638734c11..71e74d56b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -31,6 +31,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self)) tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self)) tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) + tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index ed804afd9..0edc0a350 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -39,7 +39,7 @@ class MastodonPickServerViewModel: NSObject { let selectCategoryItem = CurrentValueSubject(.all) let searchText = CurrentValueSubject("") let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading let viewWillAppear = PassthroughSubject() // output @@ -85,8 +85,8 @@ extension MastodonPickServerViewModel { private func configure() { Publishers.CombineLatest( - filteredIndexedServers.eraseToAnyPublisher(), - unindexedServers.eraseToAnyPublisher() + filteredIndexedServers, + unindexedServers ) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] indexedServers, unindexedServers in @@ -114,16 +114,31 @@ extension MastodonPickServerViewModel { guard !serverItems.contains(item) else { continue } serverItems.append(item) } - for server in unindexedServers { - let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) - attribute.isLast = false - let item = PickServerItem.server(server: server, attribute: attribute) - guard !serverItems.contains(item) else { continue } - serverItems.append(item) + + if let unindexedServers = unindexedServers { + if !unindexedServers.isEmpty { + for server in unindexedServers { + let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false) + attribute.isLast = false + let item = PickServerItem.server(server: server, attribute: attribute) + guard !serverItems.contains(item) else { continue } + serverItems.append(item) + } + } else { + if indexedServers.isEmpty && !self.isLoadingIndexedServers.value { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true))) + } + } + } else { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false))) } + if case let .server(_, attribute) = serverItems.last { attribute.isLast = true } + if case let .loader(attribute) = serverItems.last { + attribute.isLast = true + } snapshot.appendItems(serverItems, toSection: .servers) diffableDataSource.defaultRowAnimation = .fade @@ -168,6 +183,7 @@ extension MastodonPickServerViewModel { guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else { return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher() } + self.unindexedServers.value = nil return self.context.apiService.instance(domain: domain) .map { response -> Result, Error>in let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] } @@ -184,9 +200,14 @@ extension MastodonPickServerViewModel { switch result { case .success(let response): self.unindexedServers.send(response.value) - case .failure: - // TODO: What should be presented when user inputs invalid search text? - self.unindexedServers.send([]) + case .failure(let error): + if let error = error as? APIService.APIError, + case let .implicit(reason) = error, + case .badRequest = reason { + self.unindexedServers.send([]) + } else { + self.unindexedServers.send(nil) + } } }) .store(in: &disposeBag) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index a93dcfebf..8eb0cb771 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -88,11 +88,14 @@ class PickServerCell: UITableViewCell { let expandButton: UIButton = { let button = UIButton(type: .custom) + button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) button.translatesAutoresizingMaskIntoConstraints = false + button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.transform = CGAffineTransform(scaleX: -1, y: 1) return button }() @@ -325,11 +328,15 @@ extension PickServerCell { func updateExpandMode(mode: ExpandMode) { switch mode { case .collapse: + expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) expandBox.isHidden = true expandButton.isSelected = false NSLayoutConstraint.deactivate(expandConstraints) NSLayoutConstraint.activate(collapseConstraints) case .expand: + expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal) expandBox.isHidden = false expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift new file mode 100644 index 000000000..37135fa9b --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -0,0 +1,86 @@ +// +// PickServerLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-13. +// + +import UIKit +import Combine + +final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { + + let containerView: UIView = { + let view = UIView() + view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let seperator: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let emptyStatusLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.ServerPicker.EmptyState.noResults + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19) + return label + }() + + override func _init() { + super._init() + + contentView.addSubview(containerView) + contentView.addSubview(seperator) + + NSLayoutConstraint.activate([ + // Set background view + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1), + + // Set bottom separator + seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), + containerView.topAnchor.constraint(equalTo: seperator.topAnchor), + seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), + ]) + + emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(emptyStatusLabel) + NSLayoutConstraint.activate([ + emptyStatusLabel.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + containerView.readableContentGuide.trailingAnchor.constraint(equalTo: emptyStatusLabel.trailingAnchor), + emptyStatusLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + emptyStatusLabel.isHidden = true + + contentView.bringSubviewToFront(stackView) + activityIndicatorView.isHidden = false + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PickServerLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + PickServerLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index da7420e43..ded8fa49b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -16,9 +16,9 @@ class TimelineLoaderTableViewCell: UITableViewCell { static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() - - var stateBindDispose: AnyCancellable? - + + let stackView = UIStackView() + let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont @@ -86,7 +86,6 @@ class TimelineLoaderTableViewCell: UITableViewCell { ]) // use stack view to alignlment content center - let stackView = UIStackView() stackView.spacing = 4 stackView.axis = .horizontal stackView.alignment = .center diff --git a/Mastodon/Scene/Webview/WebViewController.swift b/Mastodon/Scene/Share/Webview/WebViewController.swift similarity index 100% rename from Mastodon/Scene/Webview/WebViewController.swift rename to Mastodon/Scene/Share/Webview/WebViewController.swift diff --git a/Mastodon/Scene/Webview/WebViewModel.swift b/Mastodon/Scene/Share/Webview/WebViewModel.swift similarity index 100% rename from Mastodon/Scene/Webview/WebViewModel.swift rename to Mastodon/Scene/Share/Webview/WebViewModel.swift From 1a48b38b09a4c510c5ada451ad64d7ba90367ab9 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 17:51:05 +0800 Subject: [PATCH 399/400] chore: update version to 0.4.0 (4) --- Mastodon.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 0eb170fc5..1d41296d1 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -3520,7 +3520,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3528,7 +3528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3547,7 +3547,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Mastodon/Mastodon.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "Mastodon/Resources/Preview\\ Assets.xcassets"; DEVELOPMENT_TEAM = 7LFDZ96332; INFOPLIST_FILE = Mastodon/Info.plist; @@ -3555,7 +3555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 0.4.0; PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.Mastodon; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From ff465ac18c7ac4c703a23a5329b928e8dbd012bb Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 13 May 2021 18:46:19 +0800 Subject: [PATCH 400/400] chore: update project settings for App Store --- Mastodon.xcodeproj/project.pbxproj | 28 ++----------------- .../xcschemes/xcschememanagement.plist | 4 +-- Mastodon/Info.plist | 2 ++ 3 files changed, 6 insertions(+), 28 deletions(-) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 1d41296d1..df5d04df6 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -285,13 +285,11 @@ DB6804872637CD4C00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB6804922637CD8700430867 /* AppName.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804912637CD8700430867 /* AppName.swift */; }; DB6804A52637CDCC00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; - DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB6804C82637CE2F00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; DB6804D12637CE4700430867 /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804D02637CE4700430867 /* UserDefaults.swift */; }; DB6804FD2637CFEC00430867 /* AppSecret.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6804FC2637CFEC00430867 /* AppSecret.swift */; }; DB6805102637D0F800430867 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = DB68050F2637D0F800430867 /* KeychainAccess */; }; DB6805262637D7DD00430867 /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; - DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; DB68586425E619B700F0A850 /* NSKeyValueObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68586325E619B700F0A850 /* NSKeyValueObservation.swift */; }; DB68A04A25E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB68A04925E9027700CFDF14 /* AdaptiveStatusBarStyleNavigationController.swift */; }; DB68A05D25E9055900CFDF14 /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = DB68A05C25E9055900CFDF14 /* Settings.bundle */; }; @@ -521,28 +519,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - DB6804A92637CDCC00430867 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - DB6804A62637CDCC00430867 /* AppShared.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; - DB68052A2637D7DD00430867 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - DB6805272637D7DD00430867 /* AppShared.framework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; DB89BA0825C10FD0008580ED /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -2499,7 +2475,6 @@ DB89B9EA25C10FD0008580ED /* Sources */, DB89B9EB25C10FD0008580ED /* Frameworks */, DB89B9EC25C10FD0008580ED /* Resources */, - DB68052A2637D7DD00430867 /* Embed Frameworks */, ); buildRules = ( ); @@ -2538,7 +2513,6 @@ DBF8AE0F263293E400C9C23C /* Sources */, DBF8AE10263293E400C9C23C /* Frameworks */, DBF8AE11263293E400C9C23C /* Resources */, - DB6804A92637CDCC00430867 /* Embed Frameworks */, ); buildRules = ( ); @@ -3650,6 +3624,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D7D7CF93E262178800077512 /* Pods-Mastodon-AppShared.debug.xcconfig */; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -3680,6 +3655,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = B31D44635FCF6452F7E1B865 /* Pods-Mastodon-AppShared.release.xcconfig */; buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index f092f9734..326857269 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 15 + 14 Mastodon - RTL.xcscheme_^#shared#^_ @@ -32,7 +32,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 14 + 15 SuppressBuildableAutocreation diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index 266cc9424..38651b0ef 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -2,6 +2,8 @@ + ITSAppUsesNonExemptEncryption + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable

    dY2-Z%}X?B6BgXx^KG99 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 z℘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%0AkxG5kj~J9VeaSm*R;nLbyi)>lQajIK+?Kx_nUu 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%0Di%aY@+pXXd0dUMl$aENC(5boCV_qfv<2Tlm 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